diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0215824c57..aa168fe78b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 68011dedfe..ba793a39c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index d7ea0ee358..6cf3802546 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/src/app.rs b/src/app.rs index 76b8b808a1..a66f5d90cd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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)?; diff --git a/src/components/changes.rs b/src/components/changes.rs index 0bb0f04b09..d6a56bd301 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -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, diff --git a/src/components/diff.rs b/src/components/diff.rs index 868806b333..71d357e007 100644 --- a/src/components/diff.rs +++ b/src/components/diff.rs @@ -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, pending: bool, - selection: usize, + selection: Selection, selected_hunk: Option, current_size: Cell<(u16, u16)>, focused: bool, current: Current, scroll_top: Cell, - queue: Option, + queue: Queue, theme: SharedTheme, + is_immutable: bool, } impl DiffComponent { /// - pub fn new(queue: Option, 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), }; } diff --git a/src/components/inspect_commit.rs b/src/components/inspect_commit.rs index f39d4c09af..56e3d9e141 100644 --- a/src/components/inspect_commit.rs +++ b/src/components/inspect_commit.rs @@ -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()), diff --git a/src/components/mod.rs b/src/components/mod.rs index 13ca85d03f..bf3164723c 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -105,6 +105,12 @@ pub enum ScrollType { PageDown, } +#[derive(Copy, Clone)] +pub enum Direction { + Up, + Down, +} + /// #[derive(PartialEq)] pub enum CommandBlocking { diff --git a/src/components/utils/mod.rs b/src/components/utils/mod.rs index 110dfe1a9e..a3fe5652c9 100644 --- a/src/components/utils/mod.rs +++ b/src/components/utils/mod.rs @@ -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::::from(DateTime::::from_utc( diff --git a/src/keys.rs b/src/keys.rs index ab8d930a8b..3c7020d8d3 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -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')); diff --git a/src/strings.rs b/src/strings.rs index 7edaadea5f..0f3ef7ea78 100644 --- a/src/strings.rs +++ b/src/strings.rs @@ -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", diff --git a/src/tabs/status.rs b/src/tabs/status.rs index 38673dbfd9..4aaae6ec60 100644 --- a/src/tabs/status.rs +++ b/src/tabs/status.rs @@ -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()),