diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index db9b9c51..3c059f23 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -10,20 +10,28 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - rust: [stable] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 - name: Get version id: get_version run: echo ::set-output name=version::${GITHUB_REF/refs\/tags\//} + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: clippy + - name: Build run: cargo build - name: Run tests run: make test - name: Run clippy run: | - rustup component add clippy + cargo clean make clippy - name: Setup MUSL diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88762cf7..9ddf9e45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,19 +13,29 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - rust: [stable] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v2 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + components: clippy + - name: Build Debug - run: cargo build + run: | + rustc --version + cargo build + - name: Run tests run: make test + - name: Run clippy run: | - rustup component add clippy cargo clean make clippy - name: Build Release @@ -35,9 +45,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + target: x86_64-unknown-linux-musl + - name: Setup MUSL run: | - rustup target add x86_64-unknown-linux-musl sudo apt-get -qq install musl-tools - name: Build Debug run: cargo build --target=x86_64-unknown-linux-musl @@ -51,7 +67,10 @@ jobs: steps: - uses: actions/checkout@master - name: Install Rust - run: rustup update stable && rustup default stable && rustup component add rustfmt + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt - run: cargo fmt -- --check sec: diff --git a/CHANGELOG.md b/CHANGELOG.md index 965d2334..4c883dd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - New `Stage all [a]`/`Unstage all [a]` in changes lists ([#82](https://github.com/extrawurst/gitui/issues/82)) - add `-d`, `--directory` options to set working directory ([#73](https://github.com/extrawurst/gitui/issues/73)) +- commit detail view in revlog ([#80](https://github.com/extrawurst/gitui/issues/80)) ### Fixed - app closes when staging invalid file/path ([#108](https://github.com/extrawurst/gitui/issues/108)) diff --git a/Makefile b/Makefile index 68184bbf..3cb0bd77 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ fmt: cargo fmt -- --check clippy: + touch src/main.rs cargo clean -p gitui -p asyncgit -p scopetime cargo clippy --all-features diff --git a/README.md b/README.md index 86a9a192..3d6edb07 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ presentation slides: https://github.com/extrawurst/gitui-presentation # known limitations -* no support for [bare repositories](https://git-scm.com/book/en/v2/Git-on-the-Server-Getting-Git-on-a-Server) +* no support for [bare repositories](https://git-scm.com/book/en/v2/Git-on-the-Server-Getting-Git-on-a-Server) (see [#100](https://github.com/extrawurst/gitui/issues/100)) * [core.hooksPath](https://git-scm.com/docs/githooks) config not supported * revert/reset hunk in working dir (see [#11](https://github.com/extrawurst/gitui/issues/11)) @@ -89,9 +89,7 @@ see [releases](https://github.com/extrawurst/gitui/releases) ### requirements -install `rust`/`cargo`: https://www.rust-lang.org/tools/install - -min rust version: `1.42` +install **latest** `rust`/`cargo`: https://www.rust-lang.org/tools/install ### cargo install diff --git a/asyncgit/src/commit_files.rs b/asyncgit/src/commit_files.rs new file mode 100644 index 00000000..360db0a2 --- /dev/null +++ b/asyncgit/src/commit_files.rs @@ -0,0 +1,101 @@ +use crate::{ + error::Result, sync, AsyncNotification, StatusItem, CWD, +}; +use crossbeam_channel::Sender; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, +}; +use sync::CommitId; + +type ResultType = Vec; +struct Request(R, A); + +/// +pub struct AsyncCommitFiles { + current: Arc>>>, + sender: Sender, + pending: Arc, +} + +impl AsyncCommitFiles { + /// + pub fn new(sender: &Sender) -> Self { + Self { + current: Arc::new(Mutex::new(None)), + sender: sender.clone(), + pending: Arc::new(AtomicUsize::new(0)), + } + } + + /// + pub fn current( + &mut self, + ) -> Result> { + let c = self.current.lock()?; + + if let Some(c) = c.as_ref() { + Ok(Some((c.0, c.1.clone()))) + } else { + Ok(None) + } + } + + /// + pub fn is_pending(&self) -> bool { + self.pending.load(Ordering::Relaxed) > 0 + } + + /// + pub fn fetch(&mut self, id: CommitId) -> Result<()> { + if self.is_pending() { + return Ok(()); + } + + log::trace!("request: {}", id.to_string()); + + { + let current = self.current.lock()?; + if let Some(ref c) = *current { + if c.0 == id { + return Ok(()); + } + } + } + + 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.fetch_add(1, Ordering::Relaxed); + + Self::fetch_helper(id, arc_current) + .expect("failed to fetch"); + + arc_pending.fetch_sub(1, Ordering::Relaxed); + + sender + .send(AsyncNotification::CommitFiles) + .expect("error sending"); + }); + + Ok(()) + } + + fn fetch_helper( + id: CommitId, + arc_current: Arc< + Mutex>>, + >, + ) -> Result<()> { + let res = sync::get_commit_files(CWD, id)?; + + { + let mut last = arc_current.lock()?; + *last = Some(Request(id, res)); + } + + Ok(()) + } +} diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index 689fa33f..3c973287 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::result_unwrap_used)] #![deny(clippy::panic)] +mod commit_files; mod diff; mod error; mod revlog; @@ -13,6 +14,7 @@ mod status; pub mod sync; pub use crate::{ + commit_files::AsyncCommitFiles, diff::{AsyncDiff, DiffParams}, revlog::{AsyncLog, FetchStatus}, status::{AsyncStatus, StatusParams}, @@ -27,7 +29,7 @@ use std::{ }; /// this type is used to communicate events back through the channel -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum AsyncNotification { /// Status, @@ -35,6 +37,8 @@ pub enum AsyncNotification { Diff, /// Log, + /// + CommitFiles, } /// current working director `./` diff --git a/asyncgit/src/revlog.rs b/asyncgit/src/revlog.rs index a4029751..715b4792 100644 --- a/asyncgit/src/revlog.rs +++ b/asyncgit/src/revlog.rs @@ -41,10 +41,10 @@ static SLEEP_BACKGROUND: Duration = Duration::from_millis(1000); impl AsyncLog { /// - pub fn new(sender: Sender) -> Self { + pub fn new(sender: &Sender) -> Self { Self { current: Arc::new(Mutex::new(Vec::new())), - sender, + sender: sender.clone(), pending: Arc::new(AtomicBool::new(false)), background: Arc::new(AtomicBool::new(false)), } diff --git a/asyncgit/src/sync/commit_details.rs b/asyncgit/src/sync/commit_details.rs new file mode 100644 index 00000000..36d439d6 --- /dev/null +++ b/asyncgit/src/sync/commit_details.rs @@ -0,0 +1,99 @@ +use super::{utils::repo, CommitId}; +use crate::error::Result; +use git2::Signature; +use scopetime::scope_time; + +/// +#[derive(Debug, PartialEq)] +pub struct CommitSignature { + /// + pub name: String, + /// + pub email: String, + /// time in secs since Unix epoch + pub time: i64, +} + +impl CommitSignature { + /// convert from git2-rs `Signature` + pub fn from(s: Signature<'_>) -> Self { + Self { + name: s.name().unwrap_or("").to_string(), + email: s.email().unwrap_or("").to_string(), + + time: s.when().seconds(), + } + } +} + +/// +pub struct CommitMessage { + /// first line + pub subject: String, + /// remaining lines if more than one + pub body: Option, +} + +impl CommitMessage { + pub fn from(s: &str) -> Self { + if let Some(idx) = s.find('\n') { + let (first, rest) = s.split_at(idx); + Self { + subject: first.to_string(), + body: if rest.is_empty() { + None + } else { + Some(rest.to_string()) + }, + } + } else { + Self { + subject: s.to_string(), + body: None, + } + } + } +} + +/// +pub struct CommitDetails { + /// + pub author: CommitSignature, + /// committer when differs to `author` otherwise None + pub committer: Option, + /// + pub message: Option, + /// + pub hash: String, +} + +/// +pub fn get_commit_details( + repo_path: &str, + id: CommitId, +) -> Result { + scope_time!("get_commit_details"); + + let repo = repo(repo_path)?; + + let commit = repo.find_commit(id.into())?; + + let author = CommitSignature::from(commit.author()); + let committer = CommitSignature::from(commit.committer()); + let committer = if author == committer { + None + } else { + Some(committer) + }; + + let message = commit.message().map(|m| CommitMessage::from(m)); + + let details = CommitDetails { + author, + committer, + message, + hash: id.to_string(), + }; + + Ok(details) +} diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs new file mode 100644 index 00000000..24d002bb --- /dev/null +++ b/asyncgit/src/sync/commit_files.rs @@ -0,0 +1,82 @@ +use super::{utils::repo, CommitId}; +use crate::{error::Result, StatusItem, StatusItemType}; +use git2::DiffDelta; +use scopetime::scope_time; + +/// get all files that are part of a commit +pub fn get_commit_files( + repo_path: &str, + id: CommitId, +) -> Result> { + scope_time!("get_commit_files"); + + let repo = repo(repo_path)?; + + let commit = repo.find_commit(id.into())?; + let commit_tree = commit.tree()?; + let parent = if commit.parent_count() > 0 { + Some(repo.find_commit(commit.parent_id(0)?)?.tree()?) + } else { + None + }; + + let diff = repo.diff_tree_to_tree( + parent.as_ref(), + Some(&commit_tree), + None, + )?; + + let mut res = Vec::new(); + + diff.foreach( + &mut |delta: DiffDelta<'_>, _progress| { + res.push(StatusItem { + path: delta + .new_file() + .path() + .map(|p| p.to_str().unwrap_or("").to_string()) + .unwrap_or_default(), + status: StatusItemType::from(delta.status()), + }); + true + }, + None, + None, + None, + )?; + + Ok(res) +} + +#[cfg(test)] +mod tests { + use super::get_commit_files; + use crate::{ + sync::{commit, stage_add_file, tests::repo_init, CommitId}, + StatusItemType, + }; + use std::{fs::File, io::Write, path::Path}; + + #[test] + fn test_smoke() { + let file_path = Path::new("file1.txt"); + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path = root.as_os_str().to_str().unwrap(); + + File::create(&root.join(file_path)) + .unwrap() + .write_all(b"test file1 content") + .unwrap(); + + stage_add_file(repo_path, file_path).unwrap(); + + let id = commit(repo_path, "commit msg").unwrap(); + + let diff = + get_commit_files(repo_path, CommitId::new(id)).unwrap(); + + assert_eq!(diff.len(), 1); + assert_eq!(diff[0].status, StatusItemType::New); + } +} diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index 5c1b22a7..7bd78485 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -25,6 +25,12 @@ impl ToString for CommitId { } } +impl Into for CommitId { + fn into(self) -> Oid { + self.0 + } +} + /// #[derive(Debug)] pub struct CommitInfo { diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index b9aac6cc..d2d25e5a 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -1,5 +1,7 @@ //! sync git api +mod commit_details; +mod commit_files; mod commits_info; pub mod diff; mod hooks; @@ -12,6 +14,8 @@ pub mod status; mod tags; pub mod utils; +pub use commit_details::{get_commit_details, CommitDetails}; +pub use commit_files::get_commit_files; pub use commits_info::{get_commits_info, CommitId, CommitInfo}; pub use hooks::{hooks_commit_msg, hooks_post_commit, HookResult}; pub use hunks::{stage_hunk, unstage_hunk}; diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 250491c4..8b5d44cf 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -1,7 +1,7 @@ //! sync git api for fetching a status use crate::{error::Error, error::Result, sync::utils}; -use git2::{Status, StatusOptions, StatusShow}; +use git2::{Delta, Status, StatusOptions, StatusShow}; use scopetime::scope_time; use std::path::Path; @@ -36,13 +36,25 @@ impl From for StatusItemType { } } +impl From for StatusItemType { + fn from(d: Delta) -> Self { + match d { + Delta::Added => StatusItemType::New, + Delta::Deleted => StatusItemType::Deleted, + Delta::Renamed => StatusItemType::Renamed, + Delta::Typechange => StatusItemType::Typechange, + _ => StatusItemType::Modified, + } + } +} + /// -#[derive(Default, Clone, Hash, PartialEq, Debug)] +#[derive(Clone, Hash, PartialEq, Debug)] pub struct StatusItem { /// pub path: String, /// - pub status: Option, + pub status: StatusItemType, } /// @@ -125,7 +137,7 @@ pub fn get_status_new( res.push(StatusItem { path, - status: Some(StatusItemType::from(status)), + status: StatusItemType::from(status), }); } diff --git a/src/app.rs b/src/app.rs index dba04de3..8e9579e9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -190,12 +190,11 @@ impl App { self.status_tab.update_git(ev)?; self.stashing_tab.update_git(ev)?; + self.revlog.update_git(ev)?; - match ev { - AsyncNotification::Diff => (), - AsyncNotification::Log => self.revlog.update()?, + if let AsyncNotification::Status = ev { //TODO: is that needed? - AsyncNotification::Status => self.update_commands(), + self.update_commands() } Ok(()) diff --git a/src/components/changes.rs b/src/components/changes.rs index 6619ff68..f1bec925 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -92,17 +92,15 @@ impl ChangesComponent { if let Some(tree_item) = self.selection() { if self.is_working_dir { if let FileTreeItemKind::File(i) = tree_item.kind { - if let Some(status) = i.status { - let path = Path::new(i.path.as_str()); - match status { - StatusItemType::Deleted => { - sync::stage_addremoved(CWD, path)? - } - _ => sync::stage_add_file(CWD, path)?, - }; + let path = Path::new(i.path.as_str()); + match i.status { + StatusItemType::Deleted => { + sync::stage_addremoved(CWD, path)? + } + _ => sync::stage_add_file(CWD, path)?, + }; - return Ok(true); - } + return Ok(true); } else { //TODO: check if we can handle the one file case with it aswell sync::stage_add_all( diff --git a/src/components/commit_details.rs b/src/components/commit_details.rs new file mode 100644 index 00000000..e4115a76 --- /dev/null +++ b/src/components/commit_details.rs @@ -0,0 +1,279 @@ +use super::{ + dialog_paragraph, utils::time_to_string, DrawableComponent, +}; +use crate::{strings, ui::style::Theme}; +use anyhow::Result; +use asyncgit::{ + sync::{self, CommitDetails, CommitId}, + AsyncCommitFiles, AsyncNotification, StatusItem, CWD, +}; +use crossbeam_channel::Sender; +use std::borrow::Cow; +use sync::Tags; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + style::Modifier, + widgets::Text, + Frame, +}; + +pub struct CommitDetailsComponent { + data: Option, + tags: Vec, + files: Option>, + theme: Theme, + git_commit_files: AsyncCommitFiles, +} + +impl DrawableComponent for CommitDetailsComponent { + fn draw( + &mut self, + f: &mut Frame, + rect: Rect, + ) -> Result<()> { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(8), + Constraint::Min(10), + Constraint::Length(12), + ] + .as_ref(), + ) + .split(rect); + + f.render_widget( + dialog_paragraph( + strings::commit::DETAILS_INFO_TITLE, + self.get_text_info().iter(), + ), + chunks[0], + ); + + f.render_widget( + dialog_paragraph( + strings::commit::DETAILS_MESSAGE_TITLE, + self.get_text_message().iter(), + ) + .wrap(true), + chunks[1], + ); + + let files_loading = self.files.is_none(); + let files_count = self.files.as_ref().map_or(0, Vec::len); + + let txt = self + .files + .as_ref() + .map_or(vec![], |f| self.get_text_files(f)); + + let title = if files_loading { + strings::commit::DETAILS_FILES_LOADING_TITLE.to_string() + } else { + format!( + "{} {}", + strings::commit::DETAILS_FILES_TITLE, + files_count + ) + }; + f.render_widget( + dialog_paragraph(title.as_str(), txt.iter()), + chunks[2], + ); + + Ok(()) + } +} + +impl CommitDetailsComponent { + /// + pub fn new( + sender: &Sender, + theme: &Theme, + ) -> Self { + Self { + theme: *theme, + data: None, + tags: Vec::new(), + files: None, + git_commit_files: AsyncCommitFiles::new(sender), + } + } + + /// + pub fn set_commit( + &mut self, + id: Option, + tags: &Tags, + ) -> Result<()> { + self.data = if let Some(id) = id { + sync::get_commit_details(CWD, id).ok() + } else { + None + }; + + self.tags.clear(); + self.files = None; + + if let Some(id) = id { + if let Some(tags) = tags.get(&id) { + self.tags.extend(tags.clone()); + } + + if let Some((fetched_id, res)) = + self.git_commit_files.current()? + { + if fetched_id == id { + self.files = Some(res); + } else { + self.git_commit_files.fetch(id)?; + } + } else { + self.git_commit_files.fetch(id)?; + } + } + + Ok(()) + } + + fn get_text_message(&self) -> Vec { + if let Some(ref data) = self.data { + if let Some(ref message) = data.message { + let mut res = vec![Text::Styled( + Cow::from(message.subject.clone()), + self.theme + .text(true, false) + .modifier(Modifier::BOLD), + )]; + + if let Some(ref body) = message.body { + res.push(Text::Styled( + Cow::from(body), + self.theme.text(true, false), + )); + } + + return res; + } + } + vec![] + } + + fn get_text_files<'a>( + &self, + files: &'a [StatusItem], + ) -> Vec> { + let new_line = Text::Raw(Cow::from("\n")); + + let mut res = Vec::with_capacity(files.len()); + + for file in files { + res.push(Text::Styled( + Cow::from(file.path.as_str()), + self.theme.text(true, false), + )); + res.push(new_line.clone()); + } + + res + } + + fn get_text_info(&self) -> Vec { + let new_line = Text::Raw(Cow::from("\n")); + + if let Some(ref data) = self.data { + let mut res = vec![ + Text::Styled( + Cow::from(strings::commit::DETAILS_AUTHOR), + self.theme.text(false, false), + ), + Text::Styled( + Cow::from(format!( + "{} <{}>", + data.author.name, data.author.email + )), + self.theme.text(true, false), + ), + new_line.clone(), + Text::Styled( + Cow::from(strings::commit::DETAILS_DATE), + self.theme.text(false, false), + ), + Text::Styled( + Cow::from(time_to_string( + data.author.time, + false, + )), + self.theme.text(true, false), + ), + new_line.clone(), + ]; + + if let Some(ref committer) = data.committer { + res.extend(vec![ + Text::Styled( + Cow::from(strings::commit::DETAILS_COMMITTER), + self.theme.text(false, false), + ), + Text::Styled( + Cow::from(format!( + "{} <{}>", + committer.name, committer.email + )), + self.theme.text(true, false), + ), + new_line.clone(), + Text::Styled( + Cow::from(strings::commit::DETAILS_DATE), + self.theme.text(false, false), + ), + Text::Styled( + Cow::from(time_to_string( + committer.time, + false, + )), + self.theme.text(true, false), + ), + new_line.clone(), + ]); + } + + res.extend(vec![ + Text::Styled( + Cow::from(strings::commit::DETAILS_SHA), + self.theme.text(false, false), + ), + Text::Styled( + Cow::from(data.hash.clone()), + self.theme.text(true, false), + ), + new_line.clone(), + ]); + + if !self.tags.is_empty() { + res.push(Text::Styled( + Cow::from(strings::commit::DETAILS_TAGS), + self.theme.text(false, false), + )); + + for tag in &self.tags { + res.push(Text::Styled( + Cow::from(tag), + self.theme.text(true, false), + )); + } + } + + res + } else { + vec![] + } + } + + /// + pub fn any_work_pending(&self) -> bool { + self.git_commit_files.is_pending() + } +} diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index b07d2fd0..5de76975 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -30,7 +30,7 @@ pub struct CommitList { count_total: usize, items: ItemBatch, scroll_state: (Instant, f32), - tags: Tags, + tags: Option, current_size: (u16, u16), scroll_top: usize, theme: Theme, @@ -44,7 +44,7 @@ impl CommitList { selection: 0, count_total: 0, scroll_state: (Instant::now(), 0_f32), - tags: Tags::new(), + tags: None, current_size: (0, 0), scroll_top: 0, theme: *theme, @@ -79,13 +79,24 @@ impl CommitList { } /// - pub const fn tags(&self) -> &Tags { - &self.tags + pub fn tags(&self) -> Option<&Tags> { + self.tags.as_ref() + } + + /// + pub fn has_tags(&self) -> bool { + self.tags.is_some() + } + + /// + pub fn clear(&mut self) { + self.tags = None; + self.items.clear(); } /// pub fn set_tags(&mut self, tags: Tags) { - self.tags = tags; + self.tags = Some(tags); } /// @@ -225,7 +236,9 @@ impl CommitList { .take(height) .enumerate() { - let tag = if let Some(tags) = self.tags.get(&e.id) { + let tags = if let Some(tags) = + self.tags.as_ref().and_then(|t| t.get(&e.id)) + { Some(tags.join(" ")) } else { None @@ -235,7 +248,7 @@ impl CommitList { e, idx + self.scroll_top == selection, &mut txt, - tag, + tags, &self.theme, ); } diff --git a/src/components/filetree.rs b/src/components/filetree.rs index 2f7cac26..aee6fde9 100644 --- a/src/components/filetree.rs +++ b/src/components/filetree.rs @@ -138,13 +138,9 @@ impl FileTreeComponent { format!("{} {}{}", status_char, indent_str, file) }; - let status = status_item - .status - .unwrap_or(StatusItemType::Modified); - Some(Text::Styled( Cow::from(txt), - theme.item(status, selected), + theme.item(status_item.status, selected), )) } @@ -175,17 +171,13 @@ impl FileTreeComponent { } } - fn item_status_char(item_type: Option) -> char { - if let Some(item_type) = item_type { - match item_type { - StatusItemType::Modified => 'M', - StatusItemType::New => '+', - StatusItemType::Deleted => '-', - StatusItemType::Renamed => 'R', - _ => ' ', - } - } else { - ' ' + fn item_status_char(item_type: StatusItemType) -> char { + match item_type { + StatusItemType::Modified => 'M', + StatusItemType::New => '+', + StatusItemType::Deleted => '-', + StatusItemType::Renamed => 'R', + _ => ' ', } } } diff --git a/src/components/help.rs b/src/components/help.rs index 20451360..a39e124c 100644 --- a/src/components/help.rs +++ b/src/components/help.rs @@ -157,7 +157,7 @@ impl Component for HelpComponent { } impl HelpComponent { - pub fn new(theme: &Theme) -> Self { + pub const fn new(theme: &Theme) -> Self { Self { cmds: vec![], visible: false, diff --git a/src/components/mod.rs b/src/components/mod.rs index 6df5946a..8c4d398b 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,6 +1,7 @@ mod changes; mod command; mod commit; +mod commit_details; mod commitlist; mod diff; mod filetree; @@ -14,6 +15,7 @@ use anyhow::Result; pub use changes::ChangesComponent; pub use command::{CommandInfo, CommandText}; pub use commit::CommitComponent; +pub use commit_details::CommitDetailsComponent; pub use commitlist::CommitList; use crossterm::event::Event; pub use diff::DiffComponent; diff --git a/src/components/utils/filetree.rs b/src/components/utils/filetree.rs index 34540d43..a52a0b9e 100644 --- a/src/components/utils/filetree.rs +++ b/src/components/utils/filetree.rs @@ -242,13 +242,14 @@ impl Index for FileTreeItems { #[cfg(test)] mod tests { use super::*; + use asyncgit::StatusItemType; fn string_vec_to_status(items: &[&str]) -> Vec { items .iter() .map(|a| StatusItem { path: String::from(*a), - status: None, + status: StatusItemType::Modified, }) .collect::>() } diff --git a/src/components/utils/logitems.rs b/src/components/utils/logitems.rs index 38f4cd9a..1dc2ec47 100644 --- a/src/components/utils/logitems.rs +++ b/src/components/utils/logitems.rs @@ -1,5 +1,5 @@ +use super::time_to_string; use asyncgit::sync::{CommitId, CommitInfo}; -use chrono::prelude::*; use std::slice::Iter; static SLICE_OFFSET_RELOAD_THRESHOLD: usize = 100; @@ -14,18 +14,12 @@ pub struct LogEntry { impl From for LogEntry { fn from(c: CommitInfo) -> Self { - let time = - DateTime::::from(DateTime::::from_utc( - NaiveDateTime::from_timestamp(c.time, 0), - Utc, - )); - let hash = c.id.to_string().chars().take(7).collect(); Self { author: c.author, msg: c.message, - time: time.format("%Y-%m-%d %H:%M:%S").to_string(), + time: time_to_string(c.time, true), hash_short: hash, id: c.id, } diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 2971c997..110dfe1a 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -1,3 +1,19 @@ +use chrono::{DateTime, Local, NaiveDateTime, Utc}; + pub mod filetree; pub mod logitems; pub mod statustree; + +/// helper func to convert unix time since epoch to formated time string in local timezone +pub fn time_to_string(secs: i64, short: bool) -> String { + let time = DateTime::::from(DateTime::::from_utc( + NaiveDateTime::from_timestamp(secs, 0), + Utc, + )); + time.format(if short { + "%Y-%m-%d" + } else { + "%Y-%m-%d %H:%M:%S" + }) + .to_string() +} diff --git a/src/components/utils/statustree.rs b/src/components/utils/statustree.rs index c6d5d6c5..6e5835ad 100644 --- a/src/components/utils/statustree.rs +++ b/src/components/utils/statustree.rs @@ -325,13 +325,14 @@ impl StatusTree { #[cfg(test)] mod tests { use super::*; + use asyncgit::StatusItemType; fn string_vec_to_status(items: &[&str]) -> Vec { items .iter() .map(|a| StatusItem { path: String::from(*a), - status: None, + status: StatusItemType::Modified, }) .collect::>() } diff --git a/src/keys.rs b/src/keys.rs index 2ddcfeb5..fd4058e6 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -60,3 +60,4 @@ pub const STASH_APPLY: KeyEvent = no_mod(KeyCode::Enter); pub const STASH_DROP: KeyEvent = with_mod(KeyCode::Char('D'), KeyModifiers::SHIFT); pub const CMD_BAR_TOGGLE: KeyEvent = no_mod(KeyCode::Char('.')); +pub const LOG_COMMIT_DETAILS: KeyEvent = no_mod(KeyCode::Enter); diff --git a/src/strings.rs b/src/strings.rs index 0b944f12..efc5bb40 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -28,6 +28,19 @@ pub static HELP_TITLE: &str = "Help: all commands"; pub static STASHING_FILES_TITLE: &str = "Files to Stash"; pub static STASHING_OPTIONS_TITLE: &str = "Options"; +pub mod commit { + pub static DETAILS_AUTHOR: &str = "Author: "; + pub static DETAILS_COMMITTER: &str = "Committer: "; + pub static DETAILS_SHA: &str = "SHA: "; + pub static DETAILS_DATE: &str = "Date: "; + pub static DETAILS_TAGS: &str = "Tags: "; + + pub static DETAILS_INFO_TITLE: &str = "Info"; + pub static DETAILS_MESSAGE_TITLE: &str = "Message"; + pub static DETAILS_FILES_TITLE: &str = "Files:"; + pub static DETAILS_FILES_LOADING_TITLE: &str = "Files: loading"; +} + pub mod order { pub static NAV: i8 = 1; } @@ -41,6 +54,7 @@ pub mod commands { static CMD_GROUP_COMMIT: &str = "Commit"; static CMD_GROUP_STASHING: &str = "Stashing"; static CMD_GROUP_STASHES: &str = "Stashes"; + static CMD_GROUP_LOG: &str = "Log"; /// pub static TOGGLE_TABS: CommandText = CommandText::new( @@ -233,4 +247,11 @@ pub mod commands { "drop selected stash", CMD_GROUP_STASHES, ); + + /// + pub static LOG_DETAILS_OPEN: CommandText = CommandText::new( + "Details [enter]", + "open details of selected commit", + CMD_GROUP_LOG, + ); } diff --git a/src/tabs/revlog.rs b/src/tabs/revlog.rs index 0d1c7197..4fdea591 100644 --- a/src/tabs/revlog.rs +++ b/src/tabs/revlog.rs @@ -1,24 +1,32 @@ use crate::{ components::{ visibility_blocking, CommandBlocking, CommandInfo, - CommitList, Component, DrawableComponent, + CommitDetailsComponent, CommitList, Component, + DrawableComponent, }, - strings, + keys, strings, ui::style::Theme, }; use anyhow::Result; use asyncgit::{sync, AsyncLog, AsyncNotification, FetchStatus, CWD}; use crossbeam_channel::Sender; use crossterm::event::Event; -use tui::{backend::Backend, layout::Rect, Frame}; +use strings::commands; +use tui::{ + backend::Backend, + layout::{Constraint, Direction, Layout, Rect}, + Frame, +}; const SLICE_SIZE: usize = 1200; /// pub struct Revlog { + commit_details: CommitDetailsComponent, list: CommitList, git_log: AsyncLog, visible: bool, + details_open: bool, } impl Revlog { @@ -28,15 +36,20 @@ impl Revlog { theme: &Theme, ) -> Self { Self { + commit_details: CommitDetailsComponent::new( + sender, theme, + ), list: CommitList::new(strings::LOG_TITLE, theme), - git_log: AsyncLog::new(sender.clone()), + git_log: AsyncLog::new(sender), visible: false, + details_open: false, } } /// pub fn any_work_pending(&self) -> bool { self.git_log.is_pending() + || self.commit_details.any_work_pending() } /// @@ -55,9 +68,32 @@ impl Revlog { self.fetch_commits()?; } - if self.list.tags().is_empty() { + if !self.list.has_tags() || log_changed { self.list.set_tags(sync::get_tags(CWD)?); } + + if self.details_open { + self.commit_details.set_commit( + self.list.selected_entry().map(|e| e.id), + self.list.tags().expect("tags"), + )?; + } + } + + Ok(()) + } + + /// + pub fn update_git( + &mut self, + ev: AsyncNotification, + ) -> Result<()> { + if self.visible { + match ev { + AsyncNotification::CommitFiles + | AsyncNotification::Log => self.update()?, + _ => (), + } } Ok(()) @@ -87,7 +123,23 @@ impl DrawableComponent for Revlog { f: &mut Frame, area: Rect, ) -> Result<()> { - self.list.draw(f, area)?; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage(60), + Constraint::Percentage(40), + ] + .as_ref(), + ) + .split(area); + + if self.details_open { + self.list.draw(f, chunks[0])?; + self.commit_details.draw(f, chunks[1])?; + } else { + self.list.draw(f, area)?; + } Ok(()) } @@ -96,13 +148,16 @@ impl DrawableComponent for Revlog { impl Component for Revlog { fn event(&mut self, ev: Event) -> Result { if self.visible { - let needs_update = self.list.event(ev)?; + let event_used = self.list.event(ev)?; - if needs_update { + if event_used { self.update()?; + return Ok(true); + } else if let Event::Key(keys::LOG_COMMIT_DETAILS) = ev { + self.details_open = !self.details_open; + self.update()?; + return Ok(true); } - - return Ok(needs_update); } Ok(false) @@ -117,6 +172,12 @@ impl Component for Revlog { self.list.commands(out, force_all); } + out.push(CommandInfo::new( + commands::LOG_DETAILS_OPEN, + true, + self.visible, + )); + visibility_blocking(self) } @@ -131,7 +192,7 @@ impl Component for Revlog { fn show(&mut self) -> Result<()> { self.visible = true; - self.list.items().clear(); + self.list.clear(); self.update()?; Ok(())