mirror of
https://github.com/gitui-org/gitui
synced 2026-05-24 09:28:21 +00:00
530 lines
15 KiB
Rust
530 lines
15 KiB
Rust
use crate::{
|
|
components::{
|
|
dialog_paragraph, utils::time_to_string, CommandBlocking,
|
|
CommandInfo, Component, DrawableComponent, EventState,
|
|
ScrollType,
|
|
},
|
|
keys::SharedKeyConfig,
|
|
strings::{self, order},
|
|
ui::{self, style::SharedTheme},
|
|
};
|
|
use anyhow::Result;
|
|
use asyncgit::{
|
|
sync::{self, CommitDetails, CommitId, CommitMessage},
|
|
CWD,
|
|
};
|
|
use crossterm::event::Event;
|
|
use itertools::Itertools;
|
|
use std::clone::Clone;
|
|
use std::{borrow::Cow, cell::Cell};
|
|
use sync::CommitTags;
|
|
use tui::{
|
|
backend::Backend,
|
|
layout::{Constraint, Direction, Layout, Rect},
|
|
style::{Modifier, Style},
|
|
text::{Span, Spans, Text},
|
|
Frame,
|
|
};
|
|
enum Detail {
|
|
Author,
|
|
Date,
|
|
Commiter,
|
|
Sha,
|
|
}
|
|
|
|
pub struct DetailsComponent {
|
|
data: Option<CommitDetails>,
|
|
tags: Vec<String>,
|
|
theme: SharedTheme,
|
|
focused: bool,
|
|
current_size: Cell<(u16, u16)>,
|
|
scroll_top: Cell<usize>,
|
|
key_config: SharedKeyConfig,
|
|
scroll_to_bottom_on_redraw: Cell<bool>,
|
|
}
|
|
|
|
type WrappedCommitMessage<'a> =
|
|
(Vec<Cow<'a, str>>, Vec<Cow<'a, str>>);
|
|
|
|
impl DetailsComponent {
|
|
///
|
|
pub const fn new(
|
|
theme: SharedTheme,
|
|
key_config: SharedKeyConfig,
|
|
focused: bool,
|
|
) -> Self {
|
|
Self {
|
|
data: None,
|
|
tags: Vec::new(),
|
|
theme,
|
|
focused,
|
|
current_size: Cell::new((0, 0)),
|
|
scroll_top: Cell::new(0),
|
|
scroll_to_bottom_on_redraw: Cell::new(false),
|
|
key_config,
|
|
}
|
|
}
|
|
|
|
pub fn set_commit(
|
|
&mut self,
|
|
id: Option<CommitId>,
|
|
tags: Option<CommitTags>,
|
|
) -> Result<()> {
|
|
self.tags.clear();
|
|
|
|
self.data =
|
|
id.and_then(|id| sync::get_commit_details(CWD, id).ok());
|
|
|
|
self.scroll_top.set(0);
|
|
|
|
if let Some(tags) = tags {
|
|
self.tags.extend(tags)
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn wrap_commit_details(
|
|
message: &CommitMessage,
|
|
width: usize,
|
|
) -> WrappedCommitMessage<'_> {
|
|
let wrapped_title = textwrap::wrap(&message.subject, width);
|
|
|
|
if let Some(ref body) = message.body {
|
|
let wrapped_message: Vec<Cow<'_, str>> =
|
|
textwrap::wrap(body, width).into_iter().collect();
|
|
|
|
(wrapped_title, wrapped_message)
|
|
} else {
|
|
(wrapped_title, vec![])
|
|
}
|
|
}
|
|
|
|
fn get_wrapped_lines(
|
|
data: &Option<CommitDetails>,
|
|
width: usize,
|
|
) -> WrappedCommitMessage<'_> {
|
|
if let Some(ref data) = data {
|
|
if let Some(ref message) = data.message {
|
|
return Self::wrap_commit_details(message, width);
|
|
}
|
|
}
|
|
|
|
(vec![], vec![])
|
|
}
|
|
|
|
fn get_number_of_lines(
|
|
details: &Option<CommitDetails>,
|
|
width: usize,
|
|
) -> usize {
|
|
let (wrapped_title, wrapped_message) =
|
|
Self::get_wrapped_lines(details, width);
|
|
|
|
wrapped_title.len() + wrapped_message.len()
|
|
}
|
|
|
|
fn get_theme_for_line(&self, bold: bool) -> Style {
|
|
if bold {
|
|
self.theme.text(true, false).add_modifier(Modifier::BOLD)
|
|
} else {
|
|
self.theme.text(true, false)
|
|
}
|
|
}
|
|
|
|
fn get_wrapped_text_message(
|
|
&self,
|
|
width: usize,
|
|
height: usize,
|
|
) -> Vec<Spans> {
|
|
let (wrapped_title, wrapped_message) =
|
|
Self::get_wrapped_lines(&self.data, width);
|
|
|
|
[&wrapped_title[..], &wrapped_message[..]]
|
|
.concat()
|
|
.iter()
|
|
.enumerate()
|
|
.skip(self.scroll_top.get())
|
|
.take(height)
|
|
.map(|(i, line)| {
|
|
Spans::from(vec![Span::styled(
|
|
line.clone(),
|
|
self.get_theme_for_line(i < wrapped_title.len()),
|
|
)])
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn style_detail(&self, field: &Detail) -> Span {
|
|
match field {
|
|
Detail::Author => Span::styled(
|
|
Cow::from(strings::commit::details_author(
|
|
&self.key_config,
|
|
)),
|
|
self.theme.text(false, false),
|
|
),
|
|
Detail::Date => Span::styled(
|
|
Cow::from(strings::commit::details_date(
|
|
&self.key_config,
|
|
)),
|
|
self.theme.text(false, false),
|
|
),
|
|
Detail::Commiter => Span::styled(
|
|
Cow::from(strings::commit::details_committer(
|
|
&self.key_config,
|
|
)),
|
|
self.theme.text(false, false),
|
|
),
|
|
Detail::Sha => Span::styled(
|
|
Cow::from(strings::commit::details_tags(
|
|
&self.key_config,
|
|
)),
|
|
self.theme.text(false, false),
|
|
),
|
|
}
|
|
}
|
|
|
|
#[allow(unstable_name_collisions)]
|
|
fn get_text_info(&self) -> Vec<Spans> {
|
|
if let Some(ref data) = self.data {
|
|
let mut res = vec![
|
|
Spans::from(vec![
|
|
self.style_detail(&Detail::Author),
|
|
Span::styled(
|
|
Cow::from(format!(
|
|
"{} <{}>",
|
|
data.author.name, data.author.email
|
|
)),
|
|
self.theme.text(true, false),
|
|
),
|
|
]),
|
|
Spans::from(vec![
|
|
self.style_detail(&Detail::Date),
|
|
Span::styled(
|
|
Cow::from(time_to_string(
|
|
data.author.time,
|
|
false,
|
|
)),
|
|
self.theme.text(true, false),
|
|
),
|
|
]),
|
|
];
|
|
|
|
if let Some(ref committer) = data.committer {
|
|
res.extend(vec![
|
|
Spans::from(vec![
|
|
self.style_detail(&Detail::Commiter),
|
|
Span::styled(
|
|
Cow::from(format!(
|
|
"{} <{}>",
|
|
committer.name, committer.email
|
|
)),
|
|
self.theme.text(true, false),
|
|
),
|
|
]),
|
|
Spans::from(vec![
|
|
self.style_detail(&Detail::Date),
|
|
Span::styled(
|
|
Cow::from(time_to_string(
|
|
committer.time,
|
|
false,
|
|
)),
|
|
self.theme.text(true, false),
|
|
),
|
|
]),
|
|
]);
|
|
}
|
|
|
|
res.push(Spans::from(vec![
|
|
Span::styled(
|
|
Cow::from(strings::commit::details_sha(
|
|
&self.key_config,
|
|
)),
|
|
self.theme.text(false, false),
|
|
),
|
|
Span::styled(
|
|
Cow::from(data.hash.clone()),
|
|
self.theme.text(true, false),
|
|
),
|
|
]));
|
|
|
|
if !self.tags.is_empty() {
|
|
res.push(Spans::from(
|
|
self.style_detail(&Detail::Sha),
|
|
));
|
|
|
|
res.push(Spans::from(
|
|
self.tags
|
|
.iter()
|
|
.map(|tag| {
|
|
Span::styled(
|
|
Cow::from(tag),
|
|
self.theme.text(true, false),
|
|
)
|
|
})
|
|
.intersperse(Span::styled(
|
|
Cow::from(","),
|
|
self.theme.text(true, false),
|
|
))
|
|
.collect::<Vec<Span>>(),
|
|
));
|
|
}
|
|
|
|
res
|
|
} else {
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
fn move_scroll_top(&mut self, move_type: ScrollType) -> bool {
|
|
if self.data.is_some() {
|
|
let old = self.scroll_top.get();
|
|
let width = self.current_size.get().0 as usize;
|
|
let height = self.current_size.get().1 as usize;
|
|
|
|
let number_of_lines =
|
|
Self::get_number_of_lines(&self.data, width);
|
|
|
|
let max = number_of_lines.saturating_sub(height) as usize;
|
|
|
|
let new_scroll_top = match move_type {
|
|
ScrollType::Down => old.saturating_add(1),
|
|
ScrollType::Up => old.saturating_sub(1),
|
|
ScrollType::Home => 0,
|
|
ScrollType::End => max,
|
|
_ => old,
|
|
};
|
|
|
|
let new_scroll_top = new_scroll_top.clamp(0, max);
|
|
|
|
if new_scroll_top == old {
|
|
return false;
|
|
}
|
|
|
|
self.scroll_top.set(new_scroll_top);
|
|
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
}
|
|
|
|
impl DrawableComponent for DetailsComponent {
|
|
fn draw<B: Backend>(
|
|
&self,
|
|
f: &mut Frame<B>,
|
|
rect: Rect,
|
|
) -> Result<()> {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
[Constraint::Length(8), Constraint::Min(10)].as_ref(),
|
|
)
|
|
.split(rect);
|
|
|
|
f.render_widget(
|
|
dialog_paragraph(
|
|
&strings::commit::details_info_title(
|
|
&self.key_config,
|
|
),
|
|
Text::from(self.get_text_info()),
|
|
&self.theme,
|
|
false,
|
|
),
|
|
chunks[0],
|
|
);
|
|
|
|
// We have to take the border into account which is one character on
|
|
// each side.
|
|
let border_width: u16 = 2;
|
|
|
|
let width = chunks[1].width.saturating_sub(border_width);
|
|
let height = chunks[1].height.saturating_sub(border_width);
|
|
|
|
self.current_size.set((width, height));
|
|
|
|
if self.scroll_to_bottom_on_redraw.get() {
|
|
self.scroll_top.set(
|
|
Self::get_number_of_lines(
|
|
&self.data,
|
|
usize::from(width),
|
|
)
|
|
.saturating_sub(usize::from(height)),
|
|
);
|
|
self.scroll_to_bottom_on_redraw.set(false);
|
|
}
|
|
|
|
let wrapped_lines = self.get_wrapped_text_message(
|
|
width as usize,
|
|
height as usize,
|
|
);
|
|
|
|
f.render_widget(
|
|
dialog_paragraph(
|
|
&strings::commit::details_message_title(
|
|
&self.key_config,
|
|
),
|
|
Text::from(wrapped_lines),
|
|
&self.theme,
|
|
self.focused,
|
|
),
|
|
chunks[1],
|
|
);
|
|
|
|
if self.focused {
|
|
ui::draw_scrollbar(
|
|
f,
|
|
chunks[1],
|
|
&self.theme,
|
|
Self::get_number_of_lines(&self.data, width as usize),
|
|
self.scroll_top.get(),
|
|
)
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Component for DetailsComponent {
|
|
fn commands(
|
|
&self,
|
|
out: &mut Vec<CommandInfo>,
|
|
force_all: bool,
|
|
) -> CommandBlocking {
|
|
// visibility_blocking(self)
|
|
|
|
let width = self.current_size.get().0 as usize;
|
|
let number_of_lines =
|
|
Self::get_number_of_lines(&self.data, width);
|
|
|
|
out.push(
|
|
CommandInfo::new(
|
|
strings::commands::navigate_commit_message(
|
|
&self.key_config,
|
|
),
|
|
number_of_lines > 0,
|
|
self.focused || force_all,
|
|
)
|
|
.order(order::NAV),
|
|
);
|
|
|
|
CommandBlocking::PassingOn
|
|
}
|
|
|
|
fn event(&mut self, event: Event) -> Result<EventState> {
|
|
if self.focused {
|
|
if let Event::Key(e) = event {
|
|
return Ok(if e == self.key_config.move_up {
|
|
self.move_scroll_top(ScrollType::Up).into()
|
|
} else if e == self.key_config.move_down {
|
|
self.move_scroll_top(ScrollType::Down).into()
|
|
} else if e == self.key_config.home
|
|
|| e == self.key_config.shift_up
|
|
{
|
|
self.move_scroll_top(ScrollType::Home).into()
|
|
} else if e == self.key_config.end
|
|
|| e == self.key_config.shift_down
|
|
{
|
|
self.move_scroll_top(ScrollType::End).into()
|
|
} else {
|
|
EventState::NotConsumed
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(EventState::NotConsumed)
|
|
}
|
|
|
|
fn focused(&self) -> bool {
|
|
self.focused
|
|
}
|
|
|
|
fn focus(&mut self, focus: bool) {
|
|
if focus {
|
|
self.scroll_to_bottom_on_redraw.set(true);
|
|
}
|
|
|
|
self.focused = focus;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn get_wrapped_lines(
|
|
message: &CommitMessage,
|
|
width: usize,
|
|
) -> Vec<Cow<'_, str>> {
|
|
let (wrapped_title, wrapped_message) =
|
|
DetailsComponent::wrap_commit_details(&message, width);
|
|
|
|
[&wrapped_title[..], &wrapped_message[..]].concat()
|
|
}
|
|
|
|
#[test]
|
|
fn test_textwrap() {
|
|
let message = CommitMessage::from("Commit message");
|
|
|
|
assert_eq!(
|
|
get_wrapped_lines(&message, 7),
|
|
vec!["Commit", "message"]
|
|
);
|
|
assert_eq!(
|
|
get_wrapped_lines(&message, 14),
|
|
vec!["Commit message"]
|
|
);
|
|
|
|
let message_with_newline =
|
|
CommitMessage::from("Commit message\n");
|
|
|
|
assert_eq!(
|
|
get_wrapped_lines(&message_with_newline, 7),
|
|
vec!["Commit", "message"]
|
|
);
|
|
assert_eq!(
|
|
get_wrapped_lines(&message_with_newline, 14),
|
|
vec!["Commit message"]
|
|
);
|
|
|
|
let message_with_body = CommitMessage::from(
|
|
"Commit message\nFirst line\nSecond line",
|
|
);
|
|
|
|
assert_eq!(
|
|
get_wrapped_lines(&message_with_body, 7),
|
|
vec![
|
|
"Commit", "message", "First", "line", "Second",
|
|
"line"
|
|
]
|
|
);
|
|
assert_eq!(
|
|
get_wrapped_lines(&message_with_body, 14),
|
|
vec!["Commit message", "First line", "Second line"]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_line_count {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_smoke() {
|
|
let commit = CommitDetails {
|
|
message: Some(CommitMessage {
|
|
subject: String::from("subject line"),
|
|
body: Some(String::from("body lone")),
|
|
}),
|
|
..CommitDetails::default()
|
|
};
|
|
let lines = DetailsComponent::get_number_of_lines(
|
|
&Some(commit.clone()),
|
|
50,
|
|
);
|
|
assert_eq!(lines, 2);
|
|
|
|
let lines =
|
|
DetailsComponent::get_number_of_lines(&Some(commit), 8);
|
|
assert_eq!(lines, 4);
|
|
}
|
|
}
|