Add multiple selection in diff view (#231)

* Add multiple selection in diff view
* Copy selection to clipboard in diff view
* Show error message if copy to clipboard fails
- Draw `msg` after `inspect_commit_popup` to make sure the error message
  is visible
- Move `try_or_popup!` to `utils`
This commit is contained in:
Christoph Rüßler 2020-08-19 21:02:19 +02:00 committed by GitHub
parent c8ae3b8eae
commit bc23270951
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 327 additions and 80 deletions

View file

@ -27,6 +27,11 @@ jobs:
profile: minimal
components: clippy
- name: Install dependencies for clipboard access
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get -qq install libxcb-shape0-dev libxcb-xfixes0-dev
- name: Build Debug
run: |
rustc --version
@ -54,6 +59,10 @@ jobs:
profile: minimal
target: x86_64-unknown-linux-musl
- name: Install dependencies for clipboard access
run: |
sudo apt-get -qq install libxcb-shape0-dev libxcb-xfixes0-dev
- name: Setup MUSL
run: |
sudo apt-get -qq install musl-tools

86
Cargo.lock generated
View file

@ -120,6 +120,12 @@ dependencies = [
"constant_time_eq",
]
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "bytemuck"
version = "1.2.0"
@ -175,6 +181,28 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "clipboard"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7"
dependencies = [
"clipboard-win",
"objc",
"objc-foundation",
"objc_id",
"x11-clipboard",
]
[[package]]
name = "clipboard-win"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b"
dependencies = [
"winapi",
]
[[package]]
name = "cloudabi"
version = "0.0.3"
@ -410,6 +438,7 @@ dependencies = [
"bytesize",
"chrono",
"clap",
"clipboard",
"crossbeam-channel",
"crossterm",
"dirs",
@ -576,6 +605,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "matches"
version = "0.1.8"
@ -722,6 +760,35 @@ dependencies = [
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "object"
version = "0.20.0"
@ -1283,3 +1350,22 @@ name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "x11-clipboard"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea"
dependencies = [
"xcb",
]
[[package]]
name = "xcb"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de"
dependencies = [
"libc",
"log",
]

View file

@ -40,6 +40,7 @@ serde = "1.0"
anyhow = "1.0.32"
unicode-width = "0.1"
textwrap = "0.12"
clipboard = "0.5"
[target.'cfg(not(windows))'.dependencies]
pprof = { version = "0.3", features = ["flamegraph"], optional = true }

View file

@ -515,8 +515,8 @@ impl App {
self.stashmsg_popup.draw(f, size)?;
self.reset.draw(f, size)?;
self.help.draw(f, size)?;
self.msg.draw(f, size)?;
self.inspect_commit_popup.draw(f, size)?;
self.msg.draw(f, size)?;
self.external_editor_popup.draw(f, size)?;
self.tag_commit_popup.draw(f, size)?;

View file

@ -7,7 +7,7 @@ use crate::{
components::{CommandInfo, Component},
keys,
queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem},
strings,
strings, try_or_popup,
ui::style::SharedTheme,
};
use anyhow::Result;
@ -17,22 +17,6 @@ use std::path::Path;
use strings::commands;
use tui::{backend::Backend, layout::Rect, Frame};
/// macro to simplify running code that might return Err.
/// It will show a popup in that case
#[macro_export]
macro_rules! try_or_popup {
($self:ident, $msg:literal, $e:expr) => {
if let Err(err) = $e {
$self.queue.borrow_mut().push_back(
InternalEvent::ShowErrorMsg(format!(
"{}\n{}",
$msg, err
)),
);
}
};
}
///
pub struct ChangesComponent {
title: String,

View file

@ -1,13 +1,17 @@
use super::{CommandBlocking, DrawableComponent, ScrollType};
use super::{
CommandBlocking, Direction, DrawableComponent, ScrollType,
};
use crate::{
components::{CommandInfo, Component},
keys,
queue::{Action, InternalEvent, NeedsUpdate, Queue, ResetItem},
strings::{self, commands},
try_or_popup,
ui::{calc_scroll_top, style::SharedTheme},
};
use asyncgit::{hash, sync, DiffLine, DiffLineType, FileDiff, CWD};
use bytesize::ByteSize;
use clipboard::{ClipboardContext, ClipboardProvider};
use crossterm::event::Event;
use std::{borrow::Cow, cell::Cell, cmp, path::Path};
use tui::{
@ -18,7 +22,7 @@ use tui::{
Frame,
};
use anyhow::Result;
use anyhow::{anyhow, Result};
#[derive(Default)]
struct Current {
@ -27,23 +31,91 @@ struct Current {
hash: u64,
}
///
#[derive(Clone, Copy)]
enum Selection {
Single(usize),
Multiple(usize, usize),
}
impl Selection {
fn get_start(&self) -> usize {
match self {
Self::Single(start) | Self::Multiple(start, _) => *start,
}
}
fn get_end(&self) -> usize {
match self {
Self::Single(end) | Self::Multiple(_, end) => *end,
}
}
fn get_top(&self) -> usize {
match self {
Self::Single(start) => *start,
Self::Multiple(start, end) => cmp::min(*start, *end),
}
}
fn get_bottom(&self) -> usize {
match self {
Self::Single(start) => *start,
Self::Multiple(start, end) => cmp::max(*start, *end),
}
}
fn modify(&mut self, direction: Direction, max: usize) {
let start = self.get_start();
let old_end = self.get_end();
*self = match direction {
Direction::Up => {
Self::Multiple(start, old_end.saturating_sub(1))
}
Direction::Down => {
Self::Multiple(start, cmp::min(old_end + 1, max))
}
};
}
fn contains(&self, index: usize) -> bool {
match self {
Self::Single(start) => index == *start,
Self::Multiple(start, end) => {
if start <= end {
*start <= index && index <= *end
} else {
*end <= index && index <= *start
}
}
}
}
}
///
pub struct DiffComponent {
diff: Option<FileDiff>,
pending: bool,
selection: usize,
selection: Selection,
selected_hunk: Option<usize>,
current_size: Cell<(u16, u16)>,
focused: bool,
current: Current,
scroll_top: Cell<usize>,
queue: Option<Queue>,
queue: Queue,
theme: SharedTheme,
is_immutable: bool,
}
impl DiffComponent {
///
pub fn new(queue: Option<Queue>, theme: SharedTheme) -> Self {
pub fn new(
queue: Queue,
theme: SharedTheme,
is_immutable: bool,
) -> Self {
Self {
focused: false,
queue,
@ -52,9 +124,10 @@ impl DiffComponent {
selected_hunk: None,
diff: None,
current_size: Cell::new((0, 0)),
selection: 0,
selection: Selection::Single(0),
scroll_top: Cell::new(0),
theme,
is_immutable,
}
}
///
@ -73,7 +146,7 @@ impl DiffComponent {
self.current = Current::default();
self.diff = None;
self.scroll_top.set(0);
self.selection = 0;
self.selection = Selection::Single(0);
self.selected_hunk = None;
self.pending = pending;
@ -97,12 +170,14 @@ impl DiffComponent {
hash,
};
self.selected_hunk =
Self::find_selected_hunk(&diff, self.selection)?;
self.selected_hunk = Self::find_selected_hunk(
&diff,
self.selection.get_start(),
)?;
self.diff = Some(diff);
self.scroll_top.set(0);
self.selection = 0;
self.selection = Selection::Single(0);
}
Ok(())
@ -113,37 +188,97 @@ impl DiffComponent {
move_type: ScrollType,
) -> Result<()> {
if let Some(diff) = &self.diff {
let old = self.selection;
let max = diff.lines.saturating_sub(1) as usize;
self.selection = match move_type {
ScrollType::Down => old.saturating_add(1),
ScrollType::Up => old.saturating_sub(1),
let new_start = match move_type {
ScrollType::Down => {
self.selection.get_bottom().saturating_add(1)
}
ScrollType::Up => {
self.selection.get_top().saturating_sub(1)
}
ScrollType::Home => 0,
ScrollType::End => max,
ScrollType::PageDown => {
self.selection.saturating_add(
self.selection.get_bottom().saturating_add(
self.current_size.get().1.saturating_sub(1)
as usize,
)
}
ScrollType::PageUp => {
self.selection.get_top().saturating_sub(
self.current_size.get().1.saturating_sub(1)
as usize,
)
}
ScrollType::PageUp => self.selection.saturating_sub(
self.current_size.get().1.saturating_sub(1)
as usize,
),
};
self.selection = cmp::min(max, self.selection);
self.selection =
Selection::Single(cmp::min(max, new_start));
if old != self.selection {
self.selected_hunk =
Self::find_selected_hunk(diff, self.selection)?;
}
self.selected_hunk =
Self::find_selected_hunk(diff, new_start)?;
}
Ok(())
}
fn modify_selection(
&mut self,
direction: Direction,
) -> Result<()> {
if let Some(diff) = &self.diff {
let max = diff.lines.saturating_sub(1) as usize;
self.selection.modify(direction, max);
}
Ok(())
}
fn copy_string(string: String) -> Result<()> {
let mut ctx: ClipboardContext = ClipboardProvider::new()
.map_err(|_| {
anyhow!("failed to get access to clipboard")
})?;
ctx.set_contents(string).map_err(|_| {
anyhow!("failed to set clipboard contents")
})?;
Ok(())
}
fn copy_selection(&self) -> Result<()> {
if let Some(diff) = &self.diff {
let lines_to_copy: Vec<&str> = diff
.hunks
.iter()
.flat_map(|hunk| hunk.lines.iter())
.enumerate()
.filter_map(|(i, line)| {
if self.selection.contains(i) {
Some(
line.content
.trim_matches(|c| {
c == '\n' || c == '\r'
})
.as_ref(),
)
} else {
None
}
})
.collect();
try_or_popup!(
self,
"copy to clipboard error:",
Self::copy_string(lines_to_copy.join("\n"))
);
}
Ok(())
}
fn find_selected_hunk(
diff: &FileDiff,
line_selected: usize,
@ -210,8 +345,6 @@ impl DiffComponent {
Text::Raw(Cow::from(")")),
]);
} else {
let selection = self.selection;
let min = self.scroll_top.get();
let max = min + height as usize;
@ -242,7 +375,8 @@ impl DiffComponent {
&mut res,
width,
line,
selection == line_cursor,
self.selection
.contains(line_cursor),
hunk_selected,
i == hunk_len as usize - 1,
&self.theme,
@ -369,7 +503,6 @@ impl DiffComponent {
fn queue_update(&mut self) {
self.queue
.as_ref()
.expect("try using queue in immutable diff")
.borrow_mut()
.push_back(InternalEvent::Update(NeedsUpdate::ALL));
}
@ -379,40 +512,28 @@ impl DiffComponent {
if let Some(hunk) = self.selected_hunk {
let hash = diff.hunks[hunk].header_hash;
self.queue
.as_ref()
.expect("try using queue in immutable diff")
.borrow_mut()
.push_back(InternalEvent::ConfirmAction(
Action::ResetHunk(
self.current.path.clone(),
hash,
),
));
self.queue.as_ref().borrow_mut().push_back(
InternalEvent::ConfirmAction(Action::ResetHunk(
self.current.path.clone(),
hash,
)),
);
}
}
Ok(())
}
fn reset_untracked(&self) -> Result<()> {
self.queue
.as_ref()
.expect("try using queue in immutable diff")
.borrow_mut()
.push_back(InternalEvent::ConfirmAction(Action::Reset(
ResetItem {
path: self.current.path.clone(),
is_folder: false,
},
)));
self.queue.as_ref().borrow_mut().push_back(
InternalEvent::ConfirmAction(Action::Reset(ResetItem {
path: self.current.path.clone(),
is_folder: false,
})),
);
Ok(())
}
fn is_immutable(&self) -> bool {
self.queue.is_none()
}
const fn is_stage(&self) -> bool {
self.current.is_stage
}
@ -432,7 +553,7 @@ impl DrawableComponent for DiffComponent {
self.scroll_top.set(calc_scroll_top(
self.scroll_top.get(),
self.current_size.get().1 as usize,
self.selection,
self.selection.get_end(),
));
let title =
@ -474,6 +595,12 @@ impl Component for DiffComponent {
self.focused,
));
out.push(CommandInfo::new(
commands::COPY,
true,
self.focused,
));
out.push(
CommandInfo::new(
commands::DIFF_HOME_END,
@ -483,7 +610,7 @@ impl Component for DiffComponent {
.hidden(),
);
if !self.is_immutable() {
if !self.is_immutable {
out.push(CommandInfo::new(
commands::DIFF_HUNK_REMOVE,
self.selected_hunk.is_some(),
@ -512,11 +639,19 @@ impl Component for DiffComponent {
self.move_selection(ScrollType::Down)?;
Ok(true)
}
keys::SHIFT_DOWN | keys::END => {
keys::SHIFT_DOWN => {
self.modify_selection(Direction::Down)?;
Ok(true)
}
keys::SHIFT_UP => {
self.modify_selection(Direction::Up)?;
Ok(true)
}
keys::END => {
self.move_selection(ScrollType::End)?;
Ok(true)
}
keys::HOME | keys::SHIFT_UP => {
keys::HOME => {
self.move_selection(ScrollType::Home)?;
Ok(true)
}
@ -532,7 +667,7 @@ impl Component for DiffComponent {
self.move_selection(ScrollType::PageDown)?;
Ok(true)
}
keys::ENTER if !self.is_immutable() => {
keys::ENTER if !self.is_immutable => {
if self.current.is_stage {
self.unstage_hunk()?;
} else {
@ -541,8 +676,7 @@ impl Component for DiffComponent {
Ok(true)
}
keys::DIFF_RESET_HUNK
if !self.is_immutable()
&& !self.is_stage() =>
if !self.is_immutable && !self.is_stage() =>
{
if let Some(diff) = &self.diff {
if diff.untracked {
@ -553,6 +687,10 @@ impl Component for DiffComponent {
}
Ok(true)
}
keys::COPY => {
self.copy_selection()?;
Ok(true)
}
_ => Ok(false),
};
}

View file

@ -159,7 +159,7 @@ impl InspectCommitComponent {
sender,
theme.clone(),
),
diff: DiffComponent::new(None, theme),
diff: DiffComponent::new(queue.clone(), theme, true),
commit_id: None,
tags: None,
git_diff: AsyncDiff::new(sender.clone()),

View file

@ -105,6 +105,12 @@ pub enum ScrollType {
PageDown,
}
#[derive(Copy, Clone)]
pub enum Direction {
Up,
Down,
}
///
#[derive(PartialEq)]
pub enum CommandBlocking {

View file

@ -4,6 +4,22 @@ pub mod filetree;
pub mod logitems;
pub mod statustree;
/// macro to simplify running code that might return Err.
/// It will show a popup in that case
#[macro_export]
macro_rules! try_or_popup {
($self:ident, $msg:literal, $e:expr) => {
if let Err(err) = $e {
$self.queue.borrow_mut().push_back(
InternalEvent::ShowErrorMsg(format!(
"{}\n{}",
$msg, err
)),
);
}
};
}
/// 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::<Local>::from(DateTime::<Utc>::from_utc(

View file

@ -50,6 +50,7 @@ pub const SHIFT_UP: KeyEvent =
pub const SHIFT_DOWN: KeyEvent =
with_mod(KeyCode::Down, KeyModifiers::SHIFT);
pub const ENTER: KeyEvent = no_mod(KeyCode::Enter);
pub const COPY: KeyEvent = no_mod(KeyCode::Char('y'));
pub const EDIT_FILE: KeyEvent = no_mod(KeyCode::Char('e'));
pub const STATUS_STAGE_FILE: KeyEvent = no_mod(KeyCode::Enter);
pub const STATUS_STAGE_ALL: KeyEvent = no_mod(KeyCode::Char('a'));

View file

@ -105,6 +105,12 @@ pub mod commands {
CMD_GROUP_GENERAL,
);
///
pub static COPY: CommandText = CommandText::new(
"Copy [y]",
"copy selected lines to clipboard",
CMD_GROUP_DIFF,
);
///
pub static DIFF_HOME_END: CommandText = CommandText::new(
"Jump up/down [home,end,\u{2191} up,\u{2193} down]",
"scroll to top or bottom of diff",

View file

@ -127,7 +127,7 @@ impl Status {
queue.clone(),
theme.clone(),
),
diff: DiffComponent::new(Some(queue.clone()), theme),
diff: DiffComponent::new(queue.clone(), theme, false),
git_diff: AsyncDiff::new(sender.clone()),
git_status_workdir: AsyncStatus::new(sender.clone()),
git_status_stage: AsyncStatus::new(sender.clone()),