diff --git a/Cargo.lock b/Cargo.lock index 2abe61b64a..becb044949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -816,6 +828,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "env_filter" version = "0.1.3" @@ -1178,7 +1196,9 @@ dependencies = [ "filetreelist", "fuzzy-matcher", "gh-emoji", + "git2-testing", "indexmap", + "insta", "itertools 0.14.0", "log", "notify", @@ -2050,6 +2070,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "insta" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f72d3e19488cf7d8ea52d2fc0f8754fc933398b337cd3cbdb28aaeb35159ef" +dependencies = [ + "console", + "lazy_static", + "linked-hash-map", + "regex", + "similar", +] + [[package]] name = "instability" version = "0.3.6" @@ -2259,6 +2292,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3206,6 +3245,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "similar" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" + [[package]] name = "simplelog" version = "0.12.2" diff --git a/Cargo.toml b/Cargo.toml index ae7b777f08..7f73cedb3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,8 @@ which = "7.0" [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 e8ca87477d..5feee2c75d 100644 --- a/git2-testing/src/lib.rs +++ b/git2-testing/src/lib.rs @@ -18,13 +18,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(); @@ -43,6 +48,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..2c366957e1 --- /dev/null +++ b/src/gitui.rs @@ -0,0 +1,273 @@ +use std::{cell::RefCell, time::Instant}; + +use anyhow::Result; +use asyncgit::{ + sync::{utils::repo_work_dir, RepoPath}, + AsyncGitNotification, +}; +use crossbeam_channel::{never, tick, unbounded, Receiver}; +use scopetime::scope_time; + +#[cfg(test)] +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::{ + app::{App, QuitState}, + 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( + path: RepoPath, + 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(&path)?.as_str()); + + (never(), repo_watcher.receiver()) + } + Updater::Ticker => (tick(TICK_INTERVAL), never()), + }; + + let app = App::new( + RefCell::new(path), + 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::{ + 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 theme = Theme::init(&PathBuf::new()); + let key_config = KeyConfig::default(); + + let mut gitui = + Gitui::new(path, 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 feea894491..88fad1a113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,7 @@ mod bug_report; mod clipboard; mod cmdbar; mod components; +mod gitui; mod input; mod keys; mod notify_mutex; @@ -49,12 +50,9 @@ mod watcher; use crate::{app::App, args::process_cmdline}; 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, @@ -62,14 +60,12 @@ 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::{ - cell::RefCell, io::{self, Stdout}, panic, path::Path, @@ -77,7 +73,6 @@ use std::{ time::{Duration, Instant}, }; use ui::style::Theme; -use watcher::RepoWatcher; type Terminal = ratatui::Terminal>; @@ -146,7 +141,6 @@ fn main() -> Result<()> { let mut repo_path = cliargs.repo_path; let mut terminal = start_terminal(io::stdout(), &repo_path)?; - let input = Input::new(); let updater = if cliargs.notify_watcher { Updater::NotifyWatcher @@ -159,8 +153,7 @@ fn main() -> Result<()> { app_start, repo_path.clone(), theme.clone(), - key_config.clone(), - &input, + &key_config, updater, &mut terminal, )?; @@ -180,105 +173,15 @@ fn run_app( app_start: Instant, repo: RepoPath, 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(&repo)?.as_str()); - - (never(), repo_watcher.receiver()) - } - Updater::Ticker => (tick(TICK_INTERVAL), never()), - }; - - let spinner_ticker = tick(SPINNER_INTERVAL); - - let mut app = App::new( - RefCell::new(repo), - tx_git, - tx_app, - input.clone(), - theme, - key_config, - )?; - - let mut spinner = Spinner::default(); - let mut first_update = true; + let mut gitui = Gitui::new(repo, 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<()> { @@ -302,7 +205,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;