Add blame view

This closes #484.
This commit is contained in:
Christoph Rüßler 2021-04-10 17:35:25 +02:00 committed by Stephan Dilly
parent 364c79cfd5
commit b7eed4361e
14 changed files with 718 additions and 73 deletions

View file

@ -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

189
asyncgit/src/sync/blame.rs Normal file
View file

@ -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<BlameHunk>, String)>,
}
///
pub fn blame_file(
repo_path: &str,
file_path: &str,
commit_id: &str,
) -> Result<FileBlame> {
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<BlameHunk>, 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(())
}
}

View file

@ -95,6 +95,26 @@ pub fn get_commits_info(
Ok(res)
}
///
pub fn get_commit_info(
repo_path: &str,
commit_id: &CommitId,
) -> Result<CommitInfo> {
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("<unknown>").into(),
time: commit.time().seconds(),
id: CommitId(commit.id()),
})
}
///
pub fn get_message(
c: &Commit,

View file

@ -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,

View file

@ -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<NeedsUpdate> {
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<CommandInfo> {
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)?;

View file

@ -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<String>,
file_blame: Option<FileBlame>,
table_state: std::cell::Cell<TableState>,
key_config: SharedKeyConfig,
current_height: std::cell::Cell<usize>,
}
static COMMIT_ID: &str = "HEAD";
static NO_COMMIT_ID: &str = "0000000";
static NO_AUTHOR: &str = "<no author>";
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<B: Backend>(
&self,
f: &mut Frame<B>,
area: Rect,
) -> Result<()> {
if self.is_visible() {
let path: &str = self
.path
.as_deref()
.unwrap_or("<no path for blame available>");
let title = if self.file_blame.is_some() {
format!("{} -- {} -- {}", self.title, path, COMMIT_ID)
} else {
format!(
"{} -- {} -- <no blame available>",
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<CommandInfo>,
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<bool> {
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<Row> {
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<Cell> {
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
}
}

View file

@ -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::*;

View file

@ -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<bool> {
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))

View file

@ -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};

View file

@ -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()
}

View file

@ -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},

View file

@ -56,6 +56,8 @@ pub enum InternalEvent {
///
TagCommit(CommitId),
///
BlameFile(String),
///
CreateBranch,
///
RenameBranch(String, String),

View file

@ -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 {

View file

@ -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,),),