diff --git a/asyncgit/src/lib.rs b/asyncgit/src/lib.rs index b56ae40532..c82623db11 100644 --- a/asyncgit/src/lib.rs +++ b/asyncgit/src/lib.rs @@ -77,7 +77,7 @@ pub enum AsyncNotification { Fetch, } -/// current working director `./` +/// current working directory `./` pub static CWD: &str = "./"; /// helper function to calculate the hash of an arbitrary type that implements the `Hash` trait diff --git a/asyncgit/src/sync/blame.rs b/asyncgit/src/sync/blame.rs new file mode 100644 index 0000000000..4daec7995f --- /dev/null +++ b/asyncgit/src/sync/blame.rs @@ -0,0 +1,189 @@ +//! Sync git API for fetching a file blame + +use super::{utils, CommitId}; +use crate::{error::Result, sync::get_commit_info}; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +/// A `BlameHunk` contains all the information that will be shown to the user. +#[derive(Clone, Hash, Debug, PartialEq, Eq)] +pub struct BlameHunk { + /// + pub commit_id: CommitId, + /// + pub author: String, + /// + pub time: i64, + /// `git2::BlameHunk::final_start_line` returns 1-based indices, but + /// `start_line` is 0-based because the `Vec` storing the lines starts at + /// index 0. + pub start_line: usize, + /// + pub end_line: usize, +} + +/// A `BlameFile` represents as a collection of hunks. This resembles `git2`’s +/// API. +#[derive(Default, Clone, Debug)] +pub struct FileBlame { + /// + pub path: String, + /// + pub lines: Vec<(Option, String)>, +} + +/// +pub fn blame_file( + repo_path: &str, + file_path: &str, + commit_id: &str, +) -> Result { + let repo = utils::repo(repo_path)?; + + let spec = format!("{}:{}", commit_id, file_path); + let blame = repo.blame_file(Path::new(file_path), None)?; + let object = repo.revparse_single(&spec)?; + let blob = repo.find_blob(object.id())?; + let reader = BufReader::new(blob.content()); + + let lines: Vec<(Option, String)> = reader + .lines() + .enumerate() + .map(|(i, line)| { + // Line indices in a `FileBlame` are 1-based. + let corresponding_hunk = blame.get_line(i + 1); + + if let Some(hunk) = corresponding_hunk { + let commit_id = CommitId::new(hunk.final_commit_id()); + // Line indices in a `BlameHunk` are 1-based. + let start_line = + hunk.final_start_line().saturating_sub(1); + let end_line = + start_line.saturating_add(hunk.lines_in_hunk()); + + if let Ok(commit_info) = + get_commit_info(repo_path, &commit_id) + { + let hunk = BlameHunk { + commit_id, + author: commit_info.author.clone(), + time: commit_info.time, + start_line, + end_line, + }; + + return ( + Some(hunk), + line.unwrap_or_else(|_| "".into()), + ); + } + } + + (None, line.unwrap_or_else(|_| "".into())) + }) + .collect(); + + let file_blame = FileBlame { + path: file_path.into(), + lines, + }; + + Ok(file_blame) +} + +#[cfg(test)] +mod tests { + use crate::error::Result; + use crate::sync::{ + blame_file, commit, stage_add_file, tests::repo_init_empty, + BlameHunk, + }; + use std::{ + fs::{File, OpenOptions}, + io::Write, + path::Path, + }; + + #[test] + fn test_blame() -> Result<()> { + 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(); + + assert!(matches!( + blame_file(&repo_path, "foo", "HEAD"), + Err(_) + )); + + File::create(&root.join(file_path))? + .write_all(b"line 1\n")?; + + stage_add_file(repo_path, file_path)?; + commit(repo_path, "first commit")?; + + let blame = blame_file(&repo_path, "foo", "HEAD")?; + + assert!(matches!( + blame.lines.as_slice(), + [( + Some(BlameHunk { + author, + start_line: 0, + end_line: 1, + .. + }), + line + )] if author == "name" && line == "line 1" + )); + + let mut file = OpenOptions::new() + .append(true) + .open(&root.join(file_path))?; + + file.write(b"line 2\n")?; + + stage_add_file(repo_path, file_path)?; + commit(repo_path, "second commit")?; + + let blame = blame_file(&repo_path, "foo", "HEAD")?; + + assert!(matches!( + blame.lines.as_slice(), + [ + ( + Some(BlameHunk { + start_line: 0, + end_line: 1, + .. + }), + first_line + ), + ( + Some(BlameHunk { + author, + start_line: 1, + end_line: 2, + .. + }), + second_line + ) + ] if author == "name" && first_line == "line 1" && second_line == "line 2" + )); + + file.write(b"line 3\n")?; + + let blame = blame_file(&repo_path, "foo", "HEAD")?; + + assert_eq!(blame.lines.len(), 2); + + stage_add_file(repo_path, file_path)?; + commit(repo_path, "third commit")?; + + let blame = blame_file(&repo_path, "foo", "HEAD")?; + + assert_eq!(blame.lines.len(), 3); + + Ok(()) + } +} diff --git a/asyncgit/src/sync/commits_info.rs b/asyncgit/src/sync/commits_info.rs index f5d28d1c7b..3a20f3de61 100644 --- a/asyncgit/src/sync/commits_info.rs +++ b/asyncgit/src/sync/commits_info.rs @@ -95,6 +95,26 @@ pub fn get_commits_info( Ok(res) } +/// +pub fn get_commit_info( + repo_path: &str, + commit_id: &CommitId, +) -> Result { + scope_time!("get_commit_info"); + + let repo = repo(repo_path)?; + + let commit = repo.find_commit((*commit_id).into())?; + let author = commit.author(); + + Ok(CommitInfo { + message: commit.message().unwrap_or("").into(), + author: author.name().unwrap_or("").into(), + time: commit.time().seconds(), + id: CommitId(commit.id()), + }) +} + /// pub fn get_message( c: &Commit, diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index b2dd2e1133..155ea38f4f 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -3,6 +3,7 @@ //TODO: remove once we have this activated on the toplevel #![deny(clippy::expect_used)] +pub mod blame; pub mod branch; mod commit; mod commit_details; @@ -24,6 +25,7 @@ pub mod status; mod tags; pub mod utils; +pub use blame::{blame_file, BlameHunk, FileBlame}; pub use branch::{ branch_compare_upstream, checkout_branch, config_is_pull_rebase, create_branch, delete_branch, get_branch_remote, @@ -37,7 +39,9 @@ pub use commit_details::{ get_commit_details, CommitDetails, CommitMessage, }; pub use commit_files::get_commit_files; -pub use commits_info::{get_commits_info, CommitId, CommitInfo}; +pub use commits_info::{ + get_commit_info, get_commits_info, CommitId, CommitInfo, +}; pub use diff::get_diff_commit; pub use hooks::{ hooks_commit_msg, hooks_post_commit, hooks_pre_commit, HookResult, diff --git a/src/app.rs b/src/app.rs index ff786a176a..a7b91ccf45 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,8 +2,8 @@ use crate::{ accessors, cmdbar::CommandBar, components::{ - event_pump, BranchListComponent, CommandBlocking, - CommandInfo, CommitComponent, Component, + event_pump, BlameFileComponent, BranchListComponent, + CommandBlocking, CommandInfo, CommitComponent, Component, CreateBranchComponent, DrawableComponent, ExternalEditorComponent, HelpComponent, InspectCommitComponent, MsgComponent, PullComponent, @@ -41,6 +41,7 @@ pub struct App { msg: MsgComponent, reset: ResetComponent, commit: CommitComponent, + blame_file_popup: BlameFileComponent, stashmsg_popup: StashMsgComponent, inspect_commit_popup: InspectCommitComponent, external_editor_popup: ExternalEditorComponent, @@ -93,6 +94,11 @@ impl App { theme.clone(), key_config.clone(), ), + blame_file_popup: BlameFileComponent::new( + &strings::blame_title(&key_config), + theme.clone(), + key_config.clone(), + ), stashmsg_popup: StashMsgComponent::new( queue.clone(), theme.clone(), @@ -363,6 +369,7 @@ impl App { msg, reset, commit, + blame_file_popup, stashmsg_popup, inspect_commit_popup, external_editor_popup, @@ -488,48 +495,9 @@ impl App { ) -> Result { let mut flags = NeedsUpdate::empty(); match ev { - InternalEvent::ConfirmedAction(action) => match action { - Action::Reset(r) => { - if self.status_tab.reset(&r) { - flags.insert(NeedsUpdate::ALL); - } - } - Action::StashDrop(_) | Action::StashPop(_) => { - if self.stashlist_tab.action_confirmed(&action) { - flags.insert(NeedsUpdate::ALL); - } - } - Action::ResetHunk(path, hash) => { - sync::reset_hunk(CWD, &path, hash)?; - flags.insert(NeedsUpdate::ALL); - } - Action::ResetLines(path, lines) => { - sync::discard_lines(CWD, &path, &lines)?; - flags.insert(NeedsUpdate::ALL); - } - Action::DeleteBranch(branch_ref) => { - if let Err(e) = - sync::delete_branch(CWD, &branch_ref) - { - self.queue.borrow_mut().push_back( - InternalEvent::ShowErrorMsg( - e.to_string(), - ), - ) - } else { - flags.insert(NeedsUpdate::ALL); - self.select_branch_popup.update_branches()?; - } - } - Action::ForcePush(branch, force) => self - .queue - .borrow_mut() - .push_back(InternalEvent::Push(branch, force)), - Action::PullMerge { rebase, .. } => { - self.pull_popup.try_conflict_free_merge(rebase); - flags.insert(NeedsUpdate::ALL); - } - }, + InternalEvent::ConfirmedAction(action) => { + self.process_confirmed_action(action, &mut flags)?; + } InternalEvent::ConfirmAction(action) => { self.reset.open(action)?; flags.insert(NeedsUpdate::COMMANDS); @@ -548,6 +516,10 @@ impl App { InternalEvent::TagCommit(id) => { self.tag_commit_popup.open(id)?; } + InternalEvent::BlameFile(path) => { + self.blame_file_popup.open(&path)?; + flags.insert(NeedsUpdate::ALL | NeedsUpdate::COMMANDS) + } InternalEvent::CreateBranch => { self.create_branch_popup.open()?; } @@ -586,6 +558,54 @@ impl App { Ok(flags) } + fn process_confirmed_action( + &mut self, + action: Action, + flags: &mut NeedsUpdate, + ) -> Result<()> { + match action { + Action::Reset(r) => { + if self.status_tab.reset(&r) { + flags.insert(NeedsUpdate::ALL); + } + } + Action::StashDrop(_) | Action::StashPop(_) => { + if self.stashlist_tab.action_confirmed(&action) { + flags.insert(NeedsUpdate::ALL); + } + } + Action::ResetHunk(path, hash) => { + sync::reset_hunk(CWD, &path, hash)?; + flags.insert(NeedsUpdate::ALL); + } + Action::ResetLines(path, lines) => { + sync::discard_lines(CWD, &path, &lines)?; + flags.insert(NeedsUpdate::ALL); + } + Action::DeleteBranch(branch_ref) => { + if let Err(e) = sync::delete_branch(CWD, &branch_ref) + { + self.queue.borrow_mut().push_back( + InternalEvent::ShowErrorMsg(e.to_string()), + ) + } else { + flags.insert(NeedsUpdate::ALL); + self.select_branch_popup.update_branches()?; + } + } + Action::ForcePush(branch, force) => self + .queue + .borrow_mut() + .push_back(InternalEvent::Push(branch, force)), + Action::PullMerge { rebase, .. } => { + self.pull_popup.try_conflict_free_merge(rebase); + flags.insert(NeedsUpdate::ALL); + } + }; + + Ok(()) + } + fn commands(&self, force_all: bool) -> Vec { let mut res = Vec::new(); @@ -637,6 +657,7 @@ impl App { || self.msg.is_visible() || self.stashmsg_popup.is_visible() || self.inspect_commit_popup.is_visible() + || self.blame_file_popup.is_visible() || self.external_editor_popup.is_visible() || self.tag_commit_popup.is_visible() || self.create_branch_popup.is_visible() @@ -666,6 +687,7 @@ impl App { self.stashmsg_popup.draw(f, size)?; self.help.draw(f, size)?; self.inspect_commit_popup.draw(f, size)?; + self.blame_file_popup.draw(f, size)?; self.external_editor_popup.draw(f, size)?; self.tag_commit_popup.draw(f, size)?; self.select_branch_popup.draw(f, size)?; diff --git a/src/components/blame_file.rs b/src/components/blame_file.rs new file mode 100644 index 0000000000..482a382a6d --- /dev/null +++ b/src/components/blame_file.rs @@ -0,0 +1,372 @@ +use super::{ + utils, visibility_blocking, CommandBlocking, CommandInfo, + Component, DrawableComponent, +}; +use crate::{ + components::{utils::string_width_align, ScrollType}, + keys::SharedKeyConfig, + strings, + ui::style::SharedTheme, +}; +use anyhow::Result; +use asyncgit::{ + sync::{blame_file, BlameHunk, FileBlame}, + CWD, +}; +use crossterm::event::Event; +use std::convert::TryInto; +use tui::{ + backend::Backend, + layout::{Constraint, Rect}, + text::Span, + widgets::{Block, Borders, Cell, Clear, Row, Table, TableState}, + Frame, +}; + +pub struct BlameFileComponent { + title: String, + theme: SharedTheme, + visible: bool, + path: Option, + file_blame: Option, + table_state: std::cell::Cell, + key_config: SharedKeyConfig, + current_height: std::cell::Cell, +} + +static COMMIT_ID: &str = "HEAD"; +static NO_COMMIT_ID: &str = "0000000"; +static NO_AUTHOR: &str = ""; +static AUTHOR_WIDTH: usize = 20; + +fn get_author_width(width: usize) -> usize { + (width.saturating_sub(19) / 3).max(3).min(AUTHOR_WIDTH) +} + +const fn number_of_digits(number: usize) -> usize { + let mut rest = number; + let mut result = 0; + + while rest > 0 { + rest /= 10; + result += 1; + } + + result +} + +impl DrawableComponent for BlameFileComponent { + fn draw( + &self, + f: &mut Frame, + area: Rect, + ) -> Result<()> { + if self.is_visible() { + let path: &str = self + .path + .as_deref() + .unwrap_or(""); + + let title = if self.file_blame.is_some() { + format!("{} -- {} -- {}", self.title, path, COMMIT_ID) + } else { + format!( + "{} -- {} -- ", + self.title, path + ) + }; + + let rows = self.get_rows(area.width.into()); + let author_width = get_author_width(area.width.into()); + let constraints = [ + // commit id + Constraint::Length(7), + // commit date + Constraint::Length(10), + // commit author + Constraint::Length(author_width.try_into()?), + // line number and vertical bar + Constraint::Length( + (self.get_line_number_width().saturating_add(1)) + .try_into()?, + ), + // the source code line + Constraint::Min(0), + ]; + + let table = Table::new(rows) + .widths(&constraints) + .column_spacing(1) + .highlight_style(self.theme.text(true, true)) + .block( + Block::default() + .borders(Borders::ALL) + .title(Span::styled( + title, + self.theme.title(true), + )) + .border_style(self.theme.block(true)), + ); + + let mut table_state = self.table_state.take(); + + f.render_widget(Clear, area); + f.render_stateful_widget(table, area, &mut table_state); + + self.table_state.set(table_state); + self.current_height.set(area.height.into()); + } + + Ok(()) + } +} + +impl Component for BlameFileComponent { + fn commands( + &self, + out: &mut Vec, + force_all: bool, + ) -> CommandBlocking { + if self.is_visible() || force_all { + out.push( + CommandInfo::new( + strings::commands::close_popup(&self.key_config), + true, + true, + ) + .order(1), + ); + out.push( + CommandInfo::new( + strings::commands::scroll(&self.key_config), + true, + true, + ) + .order(1), + ); + } + + visibility_blocking(self) + } + + fn event( + &mut self, + event: crossterm::event::Event, + ) -> Result { + if self.is_visible() { + if let Event::Key(key) = event { + if key == self.key_config.exit_popup { + self.hide(); + } else if key == self.key_config.move_up { + self.move_selection(ScrollType::Up); + } else if key == self.key_config.move_down { + self.move_selection(ScrollType::Down); + } else if key == self.key_config.shift_up + || key == self.key_config.home + { + self.move_selection(ScrollType::Home); + } else if key == self.key_config.shift_down + || key == self.key_config.end + { + self.move_selection(ScrollType::End); + } else if key == self.key_config.page_down { + self.move_selection(ScrollType::PageDown); + } else if key == self.key_config.page_up { + self.move_selection(ScrollType::PageUp); + } + + return Ok(true); + } + } + + Ok(false) + } + + fn is_visible(&self) -> bool { + self.visible + } + + fn hide(&mut self) { + self.visible = false + } + + fn show(&mut self) -> Result<()> { + self.visible = true; + + Ok(()) + } +} + +impl BlameFileComponent { + /// + pub fn new( + title: &str, + theme: SharedTheme, + key_config: SharedKeyConfig, + ) -> Self { + Self { + title: String::from(title), + theme, + visible: false, + path: None, + file_blame: None, + table_state: std::cell::Cell::new(TableState::default()), + key_config, + current_height: std::cell::Cell::new(0), + } + } + + /// + pub fn open(&mut self, path: &str) -> Result<()> { + self.path = Some(path.into()); + self.file_blame = blame_file(CWD, path, COMMIT_ID).ok(); + self.table_state.get_mut().select(Some(0)); + + self.show()?; + + Ok(()) + } + + /// + fn get_rows(&self, width: usize) -> Vec { + if let Some(ref file_blame) = self.file_blame { + file_blame + .lines + .iter() + .enumerate() + .map(|(i, (blame_hunk, line))| { + self.get_line_blame( + width, + i, + (blame_hunk.as_ref(), line.as_ref()), + file_blame, + ) + }) + .collect() + } else { + vec![] + } + } + + fn get_line_blame( + &self, + width: usize, + line_number: usize, + hunk_and_line: (Option<&BlameHunk>, &str), + file_blame: &FileBlame, + ) -> Row { + let (hunk_for_line, line) = hunk_and_line; + + let show_metadata = if line_number == 0 { + true + } else { + let hunk_for_previous_line = + &file_blame.lines[line_number - 1]; + + match (hunk_for_previous_line, hunk_for_line) { + ((Some(previous), _), Some(current)) => { + previous.commit_id != current.commit_id + } + _ => true, + } + }; + + let mut cells = if show_metadata { + self.get_metadata_for_line_blame(width, hunk_for_line) + } else { + vec![Cell::from(""), Cell::from(""), Cell::from("")] + }; + + let line_number_width = self.get_line_number_width(); + cells.push( + // U+2502 is BOX DRAWINGS LIGHT VERTICAL. + Cell::from(format!( + "{:>line_number_width$}\u{2502}", + line_number, + line_number_width = line_number_width, + )) + .style(self.theme.text(true, false)), + ); + cells.push( + Cell::from(String::from(line)) + .style(self.theme.text(true, false)), + ); + + Row::new(cells) + } + + fn get_metadata_for_line_blame( + &self, + width: usize, + blame_hunk: Option<&BlameHunk>, + ) -> Vec { + let commit_hash = blame_hunk.map_or_else( + || NO_COMMIT_ID.into(), + |hunk| hunk.commit_id.get_short_string(), + ); + let author_width = get_author_width(width); + let truncated_author: String = blame_hunk.map_or_else( + || NO_AUTHOR.into(), + |hunk| string_width_align(&hunk.author, author_width), + ); + let author = format!( + "{:author_width$}", + truncated_author, + author_width = AUTHOR_WIDTH + ); + let time = blame_hunk.map_or_else( + || "".into(), + |hunk| utils::time_to_string(hunk.time, true), + ); + + vec![ + Cell::from(commit_hash) + .style(self.theme.commit_hash(false)), + Cell::from(time).style(self.theme.commit_time(false)), + Cell::from(author).style(self.theme.commit_author(false)), + ] + } + + fn get_max_line_number(&self) -> usize { + self.file_blame + .as_ref() + .map_or(0, |file_blame| file_blame.lines.len() - 1) + } + + fn get_line_number_width(&self) -> usize { + let max_line_number = self.get_max_line_number(); + + number_of_digits(max_line_number) + } + + fn move_selection(&mut self, scroll_type: ScrollType) -> bool { + let mut table_state = self.table_state.take(); + + let old_selection = table_state.selected().unwrap_or(0); + let max_selection = self.get_max_line_number(); + + let new_selection = match scroll_type { + ScrollType::Up => old_selection.saturating_sub(1), + ScrollType::Down => { + old_selection.saturating_add(1).min(max_selection) + } + ScrollType::Home => 0, + ScrollType::End => max_selection, + ScrollType::PageUp => old_selection.saturating_sub( + self.current_height.get().saturating_sub(2), + ), + ScrollType::PageDown => old_selection + .saturating_add( + self.current_height.get().saturating_sub(2), + ) + .min(max_selection), + }; + + let needs_update = new_selection != old_selection; + + table_state.select(Some(new_selection)); + self.table_state.set(table_state); + + needs_update + } +} diff --git a/src/components/commitlist.rs b/src/components/commitlist.rs index a569373d7c..4e5f4cd970 100644 --- a/src/components/commitlist.rs +++ b/src/components/commitlist.rs @@ -1,8 +1,8 @@ use super::utils::logitems::{ItemBatch, LogEntry}; use crate::{ components::{ - CommandBlocking, CommandInfo, Component, DrawableComponent, - ScrollType, + utils::string_width_align, CommandBlocking, CommandInfo, + Component, DrawableComponent, ScrollType, }, keys::SharedKeyConfig, strings, @@ -22,7 +22,6 @@ use tui::{ widgets::{Block, Borders, Paragraph}, Frame, }; -use unicode_width::UnicodeWidthStr; const ELEMENTS_PER_LINE: usize = 10; @@ -383,29 +382,6 @@ impl Component for CommitList { } } -#[inline] -fn string_width_align(s: &str, width: usize) -> String { - static POSTFIX: &str = ".."; - - let len = UnicodeWidthStr::width(s); - let width_wo_postfix = width.saturating_sub(POSTFIX.len()); - - if (len >= width_wo_postfix && len <= width) - || (len <= width_wo_postfix) - { - format!("{:w$}", s, w = width) - } else { - let mut s = s.to_string(); - s.truncate(find_truncate_point(&s, width_wo_postfix)); - format!("{}{}", s, POSTFIX) - } -} - -#[inline] -fn find_truncate_point(s: &str, chars: usize) -> usize { - s.chars().take(chars).map(char::len_utf8).sum() -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/components/filetree.rs b/src/components/filetree.rs index e1bd3f23be..7ccda00ef6 100644 --- a/src/components/filetree.rs +++ b/src/components/filetree.rs @@ -388,6 +388,11 @@ impl Component for FileTreeComponent { ) .order(order::NAV), ); + out.push(CommandInfo::new( + strings::commands::blame_file(&self.key_config), + self.selection_file().is_some(), + self.focused || force_all, + )); CommandBlocking::PassingOn } @@ -395,7 +400,20 @@ impl Component for FileTreeComponent { fn event(&mut self, ev: Event) -> Result { if self.focused { if let Event::Key(e) = ev { - return if e == self.key_config.move_down { + return if e == self.key_config.blame { + match (&self.queue, self.selection_file()) { + (Some(queue), Some(status_item)) => { + queue.borrow_mut().push_back( + InternalEvent::BlameFile( + status_item.path, + ), + ); + + Ok(true) + } + _ => Ok(false), + } + } else if e == self.key_config.move_down { Ok(self.move_selection(MoveSelection::Down)) } else if e == self.key_config.move_up { Ok(self.move_selection(MoveSelection::Up)) diff --git a/src/components/mod.rs b/src/components/mod.rs index 3336d71822..24193bcce9 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,3 +1,4 @@ +mod blame_file; mod branchlist; mod changes; mod command; @@ -22,6 +23,7 @@ mod tag_commit; mod textinput; mod utils; +pub use blame_file::BlameFileComponent; pub use branchlist::BranchListComponent; pub use changes::ChangesComponent; pub use command::{CommandInfo, CommandText}; diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 2c029820fa..c322035f57 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Local, NaiveDateTime, Utc}; +use unicode_width::UnicodeWidthStr; pub mod filetree; pub mod logitems; @@ -34,3 +35,26 @@ pub fn time_to_string(secs: i64, short: bool) -> String { }) .to_string() } + +#[inline] +pub fn string_width_align(s: &str, width: usize) -> String { + static POSTFIX: &str = ".."; + + let len = UnicodeWidthStr::width(s); + let width_wo_postfix = width.saturating_sub(POSTFIX.len()); + + if (len >= width_wo_postfix && len <= width) + || (len <= width_wo_postfix) + { + format!("{:w$}", s, w = width) + } else { + let mut s = s.to_string(); + s.truncate(find_truncate_point(&s, width_wo_postfix)); + format!("{}{}", s, POSTFIX) + } +} + +#[inline] +fn find_truncate_point(s: &str, chars: usize) -> usize { + s.chars().take(chars).map(char::len_utf8).sum() +} diff --git a/src/keys.rs b/src/keys.rs index a16113db7b..1938a4e000 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -47,6 +47,7 @@ pub struct KeyConfig { pub shift_up: KeyEvent, pub shift_down: KeyEvent, pub enter: KeyEvent, + pub blame: KeyEvent, pub edit_file: KeyEvent, pub status_stage_all: KeyEvent, pub status_reset_item: KeyEvent, @@ -103,6 +104,7 @@ impl Default for KeyConfig { shift_up: KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::SHIFT}, shift_down: KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::SHIFT}, enter: KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::empty()}, + blame: KeyEvent { code: KeyCode::Char('b'), modifiers: KeyModifiers::empty()}, edit_file: KeyEvent { code: KeyCode::Char('e'), modifiers: KeyModifiers::empty()}, status_stage_all: KeyEvent { code: KeyCode::Char('a'), modifiers: KeyModifiers::empty()}, status_reset_item: KeyEvent { code: KeyCode::Char('D'), modifiers: KeyModifiers::SHIFT}, diff --git a/src/queue.rs b/src/queue.rs index a5955d4048..131de06593 100644 --- a/src/queue.rs +++ b/src/queue.rs @@ -56,6 +56,8 @@ pub enum InternalEvent { /// TagCommit(CommitId), /// + BlameFile(String), + /// CreateBranch, /// RenameBranch(String, String), diff --git a/src/strings.rs b/src/strings.rs index 8480bfb9cb..79c7c511fa 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -170,6 +170,9 @@ pub fn confirm_msg_force_push( pub fn log_title(_key_config: &SharedKeyConfig) -> String { "Commit".to_string() } +pub fn blame_title(_key_config: &SharedKeyConfig) -> String { + "Blame".to_string() +} pub fn tag_commit_popup_title( _key_config: &SharedKeyConfig, ) -> String { @@ -817,6 +820,16 @@ pub mod commands { CMD_GROUP_LOG, ) } + pub fn blame_file(key_config: &SharedKeyConfig) -> CommandText { + CommandText::new( + format!( + "Blame [{}]", + key_config.get_hint(key_config.blame), + ), + "open blame view of selected file", + CMD_GROUP_LOG, + ) + } pub fn log_tag_commit( key_config: &SharedKeyConfig, ) -> CommandText { diff --git a/vim_style_key_config.ron b/vim_style_key_config.ron index 9b8ea8c64b..a8487a1d5b 100644 --- a/vim_style_key_config.ron +++ b/vim_style_key_config.ron @@ -45,6 +45,7 @@ shift_down: ( code: Char('J'), modifiers: ( bits: 1,),), enter: ( code: Enter, modifiers: ( bits: 0,),), + blame: ( code: Char('b'), modifiers: ( bits: 0,),), edit_file: ( code: Char('I'), modifiers: ( bits: 1,),),