From 1db1f00302a75125ae085a5df7401f244ca6e9a0 Mon Sep 17 00:00:00 2001 From: Stephan Dilly Date: Tue, 5 May 2020 13:47:21 +0200 Subject: [PATCH] Log view (#41) --- Cargo.lock | 1 + Cargo.toml | 1 + Makefile | 2 +- asyncgit/src/lib.rs | 4 + asyncgit/src/revlog.rs | 105 +++++++++++ asyncgit/src/sync/commits_info.rs | 101 ++++++++++ asyncgit/src/sync/logwalker.rs | 117 ++++++++++++ asyncgit/src/sync/mod.rs | 4 + asyncgit/src/sync/utils.rs | 6 +- src/app.rs | 294 ++++++++++++++++------------- src/keys.rs | 1 + src/main.rs | 7 +- src/strings.rs | 20 +- src/tabs/mod.rs | 5 + src/tabs/revlog.rs | 296 ++++++++++++++++++++++++++++++ 15 files changed, 817 insertions(+), 147 deletions(-) create mode 100644 asyncgit/src/revlog.rs create mode 100644 asyncgit/src/sync/commits_info.rs create mode 100644 asyncgit/src/sync/logwalker.rs create mode 100644 src/tabs/mod.rs create mode 100644 src/tabs/revlog.rs diff --git a/Cargo.lock b/Cargo.lock index 197f217ca7..87f0054788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -286,6 +286,7 @@ dependencies = [ "asyncgit", "backtrace", "bitflags", + "chrono", "crossbeam-channel", "crossterm", "dirs", diff --git a/Cargo.toml b/Cargo.toml index a15c6c400b..f620f9568b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ dirs = "2.0" crossbeam-channel = "0.4" scopeguard = "1.1" bitflags = "1.2" +chrono = "0.4" backtrace = { version = "0.3" } scopetime = { path = "./scopetime", version = "0.1" } asyncgit = { path = "./asyncgit", version = "0.2" } diff --git a/Makefile b/Makefile index 41088592f0..a61934fc43 100644 --- a/Makefile +++ b/Makefile @@ -37,4 +37,4 @@ install: cargo install --path "." install-debug: - cargo install --features=timing --path "." \ No newline at end of file + cargo install --features=timing --path "." --offline \ No newline at end of file diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 5dc95abbfe..74eb4199b4 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -5,11 +5,13 @@ #![deny(clippy::all)] mod diff; +mod revlog; mod status; pub mod sync; pub use crate::{ diff::{AsyncDiff, DiffParams}, + revlog::AsyncLog, status::AsyncStatus, sync::{ diff::{DiffLine, DiffLineType, FileDiff}, @@ -30,6 +32,8 @@ pub enum AsyncNotification { Status, /// Diff, + /// + Log, } /// current working director `./` diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs new file mode 100644 index 0000000000..8df8e451b5 --- /dev/null +++ b/asyncgit/src/revlog.rs @@ -0,0 +1,105 @@ +use crate::{sync, AsyncNotification, CWD}; +use crossbeam_channel::Sender; +use git2::Oid; +use scopetime::scope_time; +use std::{ + iter::FromIterator, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, +}; +use sync::{utils::repo, LogWalker}; + +/// +pub struct AsyncLog { + current: Arc>>, + sender: Sender, + pending: Arc, +} + +static LIMIT_COUNT: usize = 1000; + +impl AsyncLog { + /// + pub fn new(sender: Sender) -> Self { + Self { + current: Arc::new(Mutex::new(Vec::new())), + sender, + pending: Arc::new(AtomicBool::new(false)), + } + } + + /// + pub fn count(&mut self) -> usize { + self.current.lock().unwrap().len() + } + + /// + pub fn get_slice( + &self, + start_index: usize, + amount: usize, + ) -> Vec { + let list = self.current.lock().unwrap(); + let list_len = list.len(); + let min = start_index.min(list_len); + let max = min + amount; + let max = max.min(list_len); + Vec::from_iter(list[min..max].iter().cloned()) + } + + /// + pub fn is_pending(&self) -> bool { + self.pending.load(Ordering::Relaxed) + } + + /// + pub fn fetch(&mut self) { + if !self.is_pending() { + self.clear(); + + let arc_current = Arc::clone(&self.current); + let sender = self.sender.clone(); + let arc_pending = Arc::clone(&self.pending); + rayon_core::spawn(move || { + arc_pending.store(true, Ordering::Relaxed); + + scope_time!("async::revlog"); + + let mut entries = Vec::with_capacity(LIMIT_COUNT); + let r = repo(CWD); + let mut walker = LogWalker::new(&r); + loop { + entries.clear(); + let res_is_err = walker + .read(&mut entries, LIMIT_COUNT) + .is_err(); + + if !res_is_err { + let mut current = arc_current.lock().unwrap(); + current.extend(entries.iter()); + } + + if res_is_err || entries.len() <= 1 { + break; + } else { + Self::notify(&sender); + } + } + + arc_pending.store(false, Ordering::Relaxed); + + Self::notify(&sender); + }); + } + } + + fn clear(&mut self) { + self.current.lock().unwrap().clear(); + } + + fn notify(sender: &Sender) { + sender.send(AsyncNotification::Log).expect("error sending"); + } +} diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs new file mode 100644 index 0000000000..950dba0788 --- /dev/null +++ b/asyncgit/src/sync/commits_info.rs @@ -0,0 +1,101 @@ +use super::utils::repo; +use git2::{Commit, Error, Oid}; +use scopetime::scope_time; + +/// +#[derive(Debug)] +pub struct CommitInfo { + /// + pub message: String, + /// + pub time: i64, + /// + pub author: String, + /// + pub hash: String, +} + +/// +pub fn get_commits_info( + repo_path: &str, + ids: &[Oid], +) -> Result, Error> { + scope_time!("get_commits_info"); + + let repo = repo(repo_path); + + let commits = ids.iter().map(|id| repo.find_commit(*id).unwrap()); + + let res = commits + .map(|c: Commit| { + let message = get_message(&c); + let author = if let Some(name) = c.author().name() { + String::from(name) + } else { + String::from("") + }; + CommitInfo { + message, + author, + time: c.time().seconds(), + hash: c.id().to_string(), + } + }) + .collect::>(); + + Ok(res) +} + +fn get_message(c: &Commit) -> String { + if let Some(msg) = c.message() { + limit_str(msg, 50) + } else { + String::from("") + } +} + +fn limit_str(s: &str, limit: usize) -> String { + if let Some(first) = s.lines().next() { + first.chars().take(limit).collect::() + } else { + String::new() + } +} + +#[cfg(test)] +mod tests { + + use super::get_commits_info; + use crate::sync::{ + commit, stage_add_file, tests::repo_init_empty, + }; + use std::{ + fs::File, + io::{Error, Write}, + path::Path, + }; + + #[test] + fn test_log() -> Result<(), Error> { + let file_path = Path::new("foo"); + let (_td, repo) = repo_init_empty(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + File::create(&root.join(file_path))?.write_all(b"a")?; + stage_add_file(repo_path, file_path); + let c1 = commit(repo_path, "commit1"); + File::create(&root.join(file_path))?.write_all(b"a")?; + stage_add_file(repo_path, file_path); + let c2 = commit(repo_path, "commit2"); + + let res = get_commits_info(repo_path, &vec![c2, c1]).unwrap(); + + assert_eq!(res.len(), 2); + assert_eq!(res[0].message.as_str(), "commit2"); + assert_eq!(res[0].author.as_str(), "name"); + assert_eq!(res[1].message.as_str(), "commit1"); + + Ok(()) + } +} diff --git a/asyncgit/src/sync/logwalker.rs b/asyncgit/src/sync/logwalker.rs new file mode 100644 index 0000000000..249c755763 --- /dev/null +++ b/asyncgit/src/sync/logwalker.rs @@ -0,0 +1,117 @@ +use git2::{Error, Oid, Repository, Revwalk}; + +/// +pub struct LogWalker<'a> { + repo: &'a Repository, + revwalk: Option>, +} + +impl<'a> LogWalker<'a> { + /// + pub fn new(repo: &'a Repository) -> Self { + Self { + repo, + revwalk: None, + } + } + + /// + pub fn read( + &mut self, + out: &mut Vec, + limit: usize, + ) -> Result { + let mut count = 0_usize; + + if self.revwalk.is_none() { + let mut walk = self.repo.revwalk()?; + walk.push_head()?; + self.revwalk = Some(walk); + } + + if let Some(ref mut walk) = self.revwalk { + for id in walk { + if let Ok(id) = id { + out.push(id); + count += 1; + + if count == limit { + break; + } + } + } + } + + Ok(count) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sync::{ + commit, get_commits_info, stage_add_file, + tests::repo_init_empty, + }; + use std::{ + fs::File, + io::{Error, Write}, + path::Path, + }; + + #[test] + fn test_limit() -> Result<(), Error> { + let file_path = Path::new("foo"); + let (_td, repo) = repo_init_empty(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + File::create(&root.join(file_path))?.write_all(b"a")?; + stage_add_file(repo_path, file_path); + commit(repo_path, "commit1"); + File::create(&root.join(file_path))?.write_all(b"a")?; + stage_add_file(repo_path, file_path); + let oid2 = commit(repo_path, "commit2"); + + let mut items = Vec::new(); + let mut walk = LogWalker::new(&repo); + walk.read(&mut items, 1).unwrap(); + + assert_eq!(items.len(), 1); + assert_eq!(items[0], oid2); + + Ok(()) + } + + #[test] + fn test_logwalker() -> Result<(), Error> { + let file_path = Path::new("foo"); + let (_td, repo) = repo_init_empty(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + File::create(&root.join(file_path))?.write_all(b"a")?; + stage_add_file(repo_path, file_path); + commit(repo_path, "commit1"); + File::create(&root.join(file_path))?.write_all(b"a")?; + stage_add_file(repo_path, file_path); + let oid2 = commit(repo_path, "commit2"); + + let mut items = Vec::new(); + let mut walk = LogWalker::new(&repo); + walk.read(&mut items, 100).unwrap(); + + let info = get_commits_info(repo_path, &items).unwrap(); + dbg!(&info); + + assert_eq!(items.len(), 2); + assert_eq!(items[0], oid2); + + let mut items = Vec::new(); + walk.read(&mut items, 100).unwrap(); + + assert_eq!(items.len(), 0); + + Ok(()) + } +} diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 60223b9579..c61cc7d68b 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -1,14 +1,18 @@ //! sync git api +mod commits_info; pub mod diff; mod hooks; mod hunks; +mod logwalker; mod reset; pub mod status; pub mod utils; +pub use commits_info::{get_commits_info, CommitInfo}; pub use hooks::{hooks_commit_msg, hooks_post_commit, HookResult}; pub use hunks::{stage_hunk, unstage_hunk}; +pub use logwalker::LogWalker; pub use reset::{ reset_stage, reset_workdir_file, reset_workdir_folder, }; diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 0190ed74c1..bf6da31574 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -1,6 +1,6 @@ //! sync git api (various methods) -use git2::{IndexAddOption, Repository, RepositoryOpenFlags}; +use git2::{IndexAddOption, Oid, Repository, RepositoryOpenFlags}; use scopetime::scope_time; use std::path::Path; @@ -31,7 +31,7 @@ pub fn repo(repo_path: &str) -> Repository { } /// this does not run any git hooks -pub fn commit(repo_path: &str, msg: &str) { +pub fn commit(repo_path: &str, msg: &str) -> Oid { scope_time!("commit"); let repo = repo(repo_path); @@ -59,7 +59,7 @@ pub fn commit(repo_path: &str, msg: &str) { &tree, parents.as_slice(), ) - .unwrap(); + .unwrap() } /// add a file diff from workingdir to stage (will not add removed files see `stage_addremoved`) diff --git a/src/app.rs b/src/app.rs index c60bd5eb9c..b4953cc765 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,6 +8,7 @@ use crate::{ keys, queue::{InternalEvent, NeedsUpdate, Queue}, strings, + tabs::Revlog, }; use asyncgit::{ current_tick, sync, AsyncDiff, AsyncNotification, AsyncStatus, @@ -75,13 +76,15 @@ pub struct App { git_diff: AsyncDiff, git_status: AsyncStatus, current_commands: Vec, + tab: usize, + revlog: Revlog, queue: Queue, } // public interface impl App { /// - pub fn new(sender: Sender) -> Self { + pub fn new(sender: &Sender) -> Self { let queue = Queue::default(); Self { focus: Focus::WorkDir, @@ -105,8 +108,10 @@ impl App { diff: DiffComponent::new(queue.clone()), msg: MsgComponent::default(), git_diff: AsyncDiff::new(sender.clone()), - git_status: AsyncStatus::new(sender), + git_status: AsyncStatus::new(sender.clone()), current_commands: Vec::new(), + tab: 0, + revlog: Revlog::new(&sender), queue, } } @@ -128,52 +133,19 @@ impl App { f.render_widget( Tabs::default() .block(Block::default().borders(Borders::BOTTOM)) - .titles(&[strings::TAB_STATUS]) + .titles(&[strings::TAB_STATUS, strings::TAB_LOG]) .style(Style::default().fg(Color::White)) .highlight_style(Style::default().fg(Color::Yellow)) - .divider(strings::TAB_DIVIDER), + .divider(strings::TAB_DIVIDER) + .select(self.tab), chunks_main[0], ); - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - if self.focus == Focus::Diff { - [ - Constraint::Percentage(30), - Constraint::Percentage(70), - ] - } else { - [ - Constraint::Percentage(50), - Constraint::Percentage(50), - ] - } - .as_ref(), - ) - .split(chunks_main[1]); - - let left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints( - if self.diff_target == DiffTarget::WorkingDir { - [ - Constraint::Percentage(60), - Constraint::Percentage(40), - ] - } else { - [ - Constraint::Percentage(40), - Constraint::Percentage(60), - ] - } - .as_ref(), - ) - .split(chunks[0]); - - self.index_wd.draw(f, left_chunks[0]); - self.index.draw(f, left_chunks[1]); - self.diff.draw(f, chunks[1]); + if self.tab == 0 { + self.draw_status_tab(f, chunks_main[1]); + } else { + self.revlog.draw(f, chunks_main[1]); + } Self::draw_commands( f, @@ -190,8 +162,13 @@ impl App { let mut flags = NeedsUpdate::empty(); - if Self::event_pump(ev, self.components_mut().as_mut_slice()) - { + let event_used = if self.tab == 0 { + Self::event_pump(ev, self.components_mut().as_mut_slice()) + } else { + self.revlog.event(ev) + }; + + if event_used { flags.insert(NeedsUpdate::COMMANDS); } else if let Event::Key(k) = ev { let new_flags = match k { @@ -219,6 +196,10 @@ impl App { self.commit.show(); NeedsUpdate::COMMANDS } + keys::TAB_TOGGLE => { + self.toggle_tabs(); + NeedsUpdate::COMMANDS + } _ => NeedsUpdate::empty(), }; @@ -253,6 +234,7 @@ impl App { match ev { AsyncNotification::Diff => self.update_diff(), AsyncNotification::Status => self.update_status(), + AsyncNotification::Log => self.revlog.update(), } } @@ -263,7 +245,9 @@ impl App { /// pub fn any_work_pending(&self) -> bool { - self.git_diff.is_pending() || self.git_status.is_pending() + self.git_diff.is_pending() + || self.git_status.is_pending() + || self.revlog.any_work_pending() } } @@ -314,6 +298,17 @@ impl App { None } + fn toggle_tabs(&mut self) { + self.tab += 1; + self.tab %= 2; + + if self.tab == 1 { + self.revlog.show(); + } else { + self.revlog.hide(); + } + } + fn can_focus_diff(&self) -> bool { match self.focus { Focus::WorkDir => self.index_wd.is_file_seleted(), @@ -401,93 +396,34 @@ impl App { fn commands(&self, force_all: bool) -> Vec { let mut res = Vec::new(); - for c in self.components() { - if c.commands(&mut res, force_all) - != CommandBlocking::PassingOn - && !force_all - { - break; - } - } - - let main_cmds_available = !self.any_popup_visible(); - - { - { - let focus_on_stage = self.focus == Focus::Stage; - let focus_not_diff = self.focus != Focus::Diff; - res.push( - CommandInfo::new( - commands::STATUS_FOCUS_UNSTAGED, - true, - main_cmds_available - && focus_on_stage - && !focus_not_diff, - ) - .hidden(), - ); - res.push( - CommandInfo::new( - commands::STATUS_FOCUS_STAGED, - true, - main_cmds_available - && !focus_on_stage - && !focus_not_diff, - ) - .hidden(), - ); - } - { - let focus_on_diff = self.focus == Focus::Diff; - res.push(CommandInfo::new( - commands::STATUS_FOCUS_LEFT, - true, - main_cmds_available && focus_on_diff, - )); - res.push(CommandInfo::new( - commands::STATUS_FOCUS_RIGHT, - self.can_focus_diff(), - main_cmds_available && !focus_on_diff, - )); + if self.revlog.is_visible() { + self.revlog.commands(&mut res, force_all); + } else { + for c in self.components() { + if c.commands(&mut res, force_all) + != CommandBlocking::PassingOn + && !force_all + { + break; + } } - res.push( - CommandInfo::new( - commands::COMMIT_OPEN, - !self.index.is_empty(), - self.offer_open_commit_cmd(), - ) - .order(-1), - ); - - res.push( - CommandInfo::new( - commands::SELECT_STAGING, - true, - self.focus == Focus::WorkDir, - ) - .order(-2), - ); - - res.push( - CommandInfo::new( - commands::SELECT_UNSTAGED, - true, - self.focus == Focus::Stage, - ) - .order(-2), - ); - - res.push( - CommandInfo::new( - commands::QUIT, - true, - main_cmds_available, - ) - .order(100), + //TODO: move into status tab component + self.add_commands_status_tab( + &mut res, + !self.any_popup_visible(), ); } + res.push( + CommandInfo::new( + commands::QUIT, + true, + !self.any_popup_visible(), + ) + .order(100), + ); + res } @@ -509,6 +445,62 @@ impl App { false } + fn add_commands_status_tab( + &self, + res: &mut Vec, + main_cmds_available: bool, + ) { + { + let focus_on_diff = self.focus == Focus::Diff; + res.push(CommandInfo::new( + commands::STATUS_FOCUS_LEFT, + true, + main_cmds_available && focus_on_diff, + )); + res.push(CommandInfo::new( + commands::STATUS_FOCUS_RIGHT, + self.can_focus_diff(), + main_cmds_available && !focus_on_diff, + )); + } + + res.push( + CommandInfo::new( + commands::COMMIT_OPEN, + !self.index.is_empty(), + main_cmds_available && self.offer_open_commit_cmd(), + ) + .order(-1), + ); + + res.push( + CommandInfo::new( + commands::SELECT_STATUS, + true, + main_cmds_available && self.focus == Focus::Diff, + ) + .hidden(), + ); + + res.push( + CommandInfo::new( + commands::SELECT_STAGING, + true, + main_cmds_available && self.focus == Focus::WorkDir, + ) + .order(-2), + ); + + res.push( + CommandInfo::new( + commands::SELECT_UNSTAGED, + true, + main_cmds_available && self.focus == Focus::Stage, + ) + .order(-2), + ); + } + fn any_popup_visible(&self) -> bool { self.commit.is_visible() || self.help.is_visible() @@ -525,6 +517,52 @@ impl App { self.msg.draw(f, size); } + fn draw_status_tab( + &self, + f: &mut Frame, + area: Rect, + ) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + if self.focus == Focus::Diff { + [ + Constraint::Percentage(30), + Constraint::Percentage(70), + ] + } else { + [ + Constraint::Percentage(50), + Constraint::Percentage(50), + ] + } + .as_ref(), + ) + .split(area); + + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + if self.diff_target == DiffTarget::WorkingDir { + [ + Constraint::Percentage(60), + Constraint::Percentage(40), + ] + } else { + [ + Constraint::Percentage(40), + Constraint::Percentage(60), + ] + } + .as_ref(), + ) + .split(chunks[0]); + + self.index_wd.draw(f, left_chunks[0]); + self.index.draw(f, left_chunks[1]); + self.diff.draw(f, chunks[1]); + } + fn draw_commands( f: &mut Frame, r: Rect, diff --git a/src/keys.rs b/src/keys.rs index ee183dfc4e..61a6d49d18 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -14,6 +14,7 @@ const fn with_mod( KeyEvent { code, modifiers } } +pub const TAB_TOGGLE: KeyEvent = no_mod(KeyCode::Tab); pub const FOCUS_WORKDIR: KeyEvent = no_mod(KeyCode::Char('1')); pub const FOCUS_STAGE: KeyEvent = no_mod(KeyCode::Char('2')); pub const FOCUS_RIGHT: KeyEvent = no_mod(KeyCode::Right); diff --git a/src/main.rs b/src/main.rs index 85d0a627e8..fcf978bbff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod poll; mod queue; mod spinner; mod strings; +mod tabs; mod ui; mod version; @@ -59,13 +60,13 @@ fn main() -> Result<()> { disable_raw_mode().unwrap(); } + set_panic_handlers(); + let mut terminal = start_terminal(io::stdout())?; let (tx_git, rx_git) = unbounded(); - let mut app = App::new(tx_git); - - set_panic_handlers(); + let mut app = App::new(&tx_git); let rx_input = poll::start_polling_thread(); let ticker = tick(TICK_INTERVAL); diff --git a/src/strings.rs b/src/strings.rs index 3a8741dd4b..90e8e308ca 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -3,6 +3,7 @@ pub static TITLE_DIFF: &str = "Diff"; pub static TITLE_INDEX: &str = "Staged Changes [2]"; pub static TAB_STATUS: &str = "Status"; +pub static TAB_LOG: &str = "Log"; pub static TAB_DIVIDER: &str = " | "; pub static CMD_SPLITTER: &str = " "; @@ -73,6 +74,12 @@ pub mod commands { CMD_GROUP_GENERAL, ); /// + pub static SELECT_STATUS: CommandText = CommandText::new( + "Focus Files [1,2]", + "focus/select file tree of staged or unstaged files", + CMD_GROUP_GENERAL, + ); + /// pub static SELECT_UNSTAGED: CommandText = CommandText::new( "Focus Unstaged [1]", "focus/select unstaged area", @@ -108,18 +115,7 @@ pub mod commands { "revert changes in selected file or entire path", CMD_GROUP_CHANGES, ); - /// - pub static STATUS_FOCUS_UNSTAGED: CommandText = CommandText::new( - "Unstaged [1]", - "view changes in working dir", - CMD_GROUP_GENERAL, - ); - /// - pub static STATUS_FOCUS_STAGED: CommandText = CommandText::new( - "Staged [2]", - "view staged changes", - CMD_GROUP_GENERAL, - ); + /// pub static STATUS_FOCUS_LEFT: CommandText = CommandText::new( "Back [\u{2190}]", //← diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs new file mode 100644 index 0000000000..5312baeb67 --- /dev/null +++ b/src/tabs/mod.rs @@ -0,0 +1,5 @@ +mod revlog; + +//TODO: tab traits? + +pub use revlog::Revlog; diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs new file mode 100644 index 0000000000..a7acc961d9 --- /dev/null +++ b/src/tabs/revlog.rs @@ -0,0 +1,296 @@ +use crate::{ + components::{CommandBlocking, CommandInfo, Component}, + keys, + strings::commands, +}; +use asyncgit::{sync, AsyncLog, AsyncNotification, CWD}; +use chrono::prelude::*; +use crossbeam_channel::Sender; +use crossterm::event::Event; +use std::{borrow::Cow, cmp, convert::TryFrom, time::Instant}; +use sync::CommitInfo; +use tui::{ + backend::Backend, + layout::{Alignment, Rect}, + style::{Color, Style}, + widgets::{Block, Borders, Paragraph, Text}, + Frame, +}; + +struct LogEntry { + time: String, + author: String, + msg: String, + hash: String, +} + +impl From<&CommitInfo> for LogEntry { + fn from(c: &CommitInfo) -> Self { + let time = + DateTime::::from(DateTime::::from_utc( + NaiveDateTime::from_timestamp(c.time, 0), + Utc, + )); + Self { + author: c.author.clone(), + msg: c.message.clone(), + time: time.format("%Y-%m-%d %H:%M:%S").to_string(), + hash: c.hash[0..7].to_string(), + } + } +} + +const COLOR_SELECTION_BG: Color = Color::Blue; + +const STYLE_HASH: Style = Style::new().fg(Color::Magenta); +const STYLE_TIME: Style = Style::new().fg(Color::Blue); +const STYLE_AUTHOR: Style = Style::new().fg(Color::Green); +const STYLE_MSG: Style = Style::new().fg(Color::Reset); + +const STYLE_HASH_SELECTED: Style = + Style::new().fg(Color::Magenta).bg(COLOR_SELECTION_BG); +const STYLE_TIME_SELECTED: Style = + Style::new().fg(Color::White).bg(COLOR_SELECTION_BG); +const STYLE_AUTHOR_SELECTED: Style = + Style::new().fg(Color::Green).bg(COLOR_SELECTION_BG); +const STYLE_MSG_SELECTED: Style = + Style::new().fg(Color::Reset).bg(COLOR_SELECTION_BG); + +static ELEMENTS_PER_LINE: usize = 8; +static SLICE_SIZE: usize = 1000; +static SLICE_OFFSET_RELOAD_THRESHOLD: usize = 100; + +/// +pub struct Revlog { + selection: usize, + selection_max: usize, + items: Vec, + git_log: AsyncLog, + visible: bool, + first_open_done: bool, + scroll_state: (Instant, f32), +} + +impl Revlog { + /// + pub fn new(sender: &Sender) -> Self { + Self { + items: Vec::new(), + git_log: AsyncLog::new(sender.clone()), + selection: 0, + selection_max: 0, + visible: false, + first_open_done: false, + scroll_state: (Instant::now(), 0_f32), + } + } + + /// + pub fn draw(&self, f: &mut Frame, area: Rect) { + let height = area.height as usize; + let selection = self.selection; + let height_d2 = height as usize / 2; + let min = selection.saturating_sub(height_d2); + + let mut txt = Vec::new(); + for (idx, e) in self.items.iter().enumerate() { + Self::add_entry(e, idx == selection, &mut txt); + } + + let title = + format!("commit {}/{}", selection, self.selection_max); + + f.render_widget( + Paragraph::new( + txt.iter() + .skip(min * ELEMENTS_PER_LINE) + .take(height * ELEMENTS_PER_LINE), + ) + .block( + Block::default() + .borders(Borders::ALL) + .title(title.as_str()), + ) + .alignment(Alignment::Left), + area, + ); + } + + /// + pub fn any_work_pending(&self) -> bool { + self.git_log.is_pending() + } + + /// + pub fn update(&mut self) { + let next_idx = self.items.len(); + + let requires_more_data = next_idx + .saturating_sub(self.selection) + < SLICE_OFFSET_RELOAD_THRESHOLD; + + self.selection_max = self.git_log.count().saturating_sub(1); + + if requires_more_data { + let commits = sync::get_commits_info( + CWD, + &self.git_log.get_slice(next_idx, SLICE_SIZE), + ); + + if let Ok(commits) = commits { + self.items.extend(commits.iter().map(LogEntry::from)); + } + } + } + + fn move_selection(&mut self, up: bool) { + self.update_scroll_speed(); + + #[allow(clippy::cast_possible_truncation)] + let speed_int = usize::try_from(self.scroll_state.1 as i64) + .unwrap() + .max(1); + + if up { + self.selection = self.selection.saturating_sub(speed_int); + } else { + self.selection = self.selection.saturating_add(speed_int); + } + + self.selection = cmp::min(self.selection, self.selection_max); + + self.update(); + } + + fn update_scroll_speed(&mut self) { + const REPEATED_SCROLL_THRESHOLD_MILLIS: u128 = 300; + const SCROLL_SPEED_START: f32 = 0.1_f32; + const SCROLL_SPEED_MAX: f32 = 10_f32; + const SCROLL_SPEED_MULTIPLIER: f32 = 1.05_f32; + + let now = Instant::now(); + + let since_last_scroll = + now.duration_since(self.scroll_state.0); + + self.scroll_state.0 = now; + + let speed = if since_last_scroll.as_millis() + < REPEATED_SCROLL_THRESHOLD_MILLIS + { + self.scroll_state.1 * SCROLL_SPEED_MULTIPLIER + } else { + SCROLL_SPEED_START + }; + + self.scroll_state.1 = speed.min(SCROLL_SPEED_MAX); + } + + fn add_entry<'a>( + e: &'a LogEntry, + selected: bool, + txt: &mut Vec>, + ) { + let count_before = txt.len(); + + let splitter_txt = Cow::from(" "); + let splitter = if selected { + Text::Styled( + splitter_txt, + Style::new().bg(COLOR_SELECTION_BG), + ) + } else { + Text::Raw(splitter_txt) + }; + + txt.push(Text::Styled( + Cow::from(e.hash.as_str()), + if selected { + STYLE_HASH_SELECTED + } else { + STYLE_HASH + }, + )); + txt.push(splitter.clone()); + txt.push(Text::Styled( + Cow::from(e.time.as_str()), + if selected { + STYLE_TIME_SELECTED + } else { + STYLE_TIME + }, + )); + txt.push(splitter.clone()); + txt.push(Text::Styled( + Cow::from(e.author.as_str()), + if selected { + STYLE_AUTHOR_SELECTED + } else { + STYLE_AUTHOR + }, + )); + txt.push(splitter); + txt.push(Text::Styled( + Cow::from(e.msg.as_str()), + if selected { + STYLE_MSG_SELECTED + } else { + STYLE_MSG + }, + )); + txt.push(Text::Raw(Cow::from("\n"))); + + assert_eq!(txt.len() - count_before, ELEMENTS_PER_LINE); + } +} + +impl Component for Revlog { + fn event(&mut self, ev: Event) -> bool { + if let Event::Key(k) = ev { + return match k { + keys::MOVE_UP => { + self.move_selection(true); + true + } + keys::MOVE_DOWN => { + self.move_selection(false); + true + } + _ => false, + }; + } + + false + } + + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + out.push(CommandInfo::new( + commands::SCROLL, + self.visible, + self.visible || force_all, + )); + + CommandBlocking::PassingOn + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false; + } + + fn show(&mut self) { + self.visible = true; + + if !self.first_open_done { + self.first_open_done = true; + self.git_log.fetch(); + } + } +}