From c74b77311eed47b40b272ca2fbe84c4fde64e5ec Mon Sep 17 00:00:00 2001 From: Giant Date: Tue, 2 Dec 2025 16:01:06 +0100 Subject: [PATCH 1/7] copy column values to clipboard --- src/app.rs | 23 ++++++++++++++++++++ src/csv.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/find.rs | 9 ++++++++ src/view.rs | 22 ++++++++++++++++++++ 4 files changed, 114 insertions(+) diff --git a/src/app.rs b/src/app.rs index 05a5e1e..76f1f5e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -594,6 +594,29 @@ impl App { .transient_message .replace(format!("Failed to copy to clipboard: {e}")), }; + } else if let SelectionType::Column = self.rows_view.selection.selection_type() { + let indices = if self.rows_view.is_filter() { + self.finder.as_ref().map(|f| f.get_all_found_indices()) + } else { + None + }; + if let Some((column_index, column_values)) = self + .rows_view + .get_column_values_from_selection(indices.as_ref()) + { + match self.clipboard.as_mut().map(|c| c.set_text(&column_values)) { + Ok(_) => self + .transient_message + .replace(format!("Copied column {} to clipboard", column_index)), + Err(e) => self.transient_message.replace(format!( + "Failed to copy column {} to clipboard: {}", + column_index, e + )), + } + } else { + self.transient_message + .replace(format!("Trying to copy a column")) + }; } } Control::FileChanged => { diff --git a/src/csv.rs b/src/csv.rs index 6b59b0b..bd8b7f3 100644 --- a/src/csv.rs +++ b/src/csv.rs @@ -330,6 +330,66 @@ impl CsvLensReader { thread::sleep(time::Duration::from_millis(100)); } } + + pub fn get_column_values(&self, column_index: usize) -> CsvlensResult> { + let mut reader = self.config.new_reader()?; + let mut values = vec![]; + for result in reader.records() { + let record = result?; + if let Some(field) = record.get(column_index) { + values.push(field.to_string()); + } + } + Ok(values) + } + + pub fn get_column_values_for_indices( + &self, + column_index: usize, + indices: &[u64], + ) -> CsvlensResult> { + let mut get_row_indices = indices + .iter() + .enumerate() + .map(|x| GetRowIndex { + record_index: *x.1, + order_index: x.0, + }) + .collect::>(); + get_row_indices.sort_by(|a, b| a.record_index.cmp(&b.record_index)); + + let mut reader = self.config.new_reader()?; + let mut values = vec![String::new(); indices.len()]; + + let mut indices_iter = get_row_indices.iter(); + let mut next_wanted = indices_iter.next(); + let mut current_record_index = 0; + + if next_wanted.is_none() { + return Ok(values); + } + + for result in reader.records() { + let record = result?; + while let Some(wanted) = next_wanted { + if current_record_index == wanted.record_index { + if let Some(field) = record.get(column_index) { + values[wanted.order_index] = field.to_string(); + } + next_wanted = indices_iter.next(); + } else if current_record_index > wanted.record_index { + next_wanted = indices_iter.next(); + } else { + break; + } + } + if next_wanted.is_none() { + break; + } + current_record_index += 1; + } + Ok(values) + } } #[derive(Debug, Clone, PartialEq)] diff --git a/src/find.rs b/src/find.rs index a837fd4..ae99069 100644 --- a/src/find.rs +++ b/src/find.rs @@ -425,6 +425,15 @@ impl Finder { indices } + pub fn get_all_found_indices(&self) -> Vec { + let m_guard = self.internal.lock().unwrap(); + m_guard + .founds + .iter() + .map(|x| x.row_index() as u64) + .collect() + } + #[cfg(test)] pub fn wait_internal(&self) { loop { diff --git a/src/view.rs b/src/view.rs index db40957..054abda 100644 --- a/src/view.rs +++ b/src/view.rs @@ -358,6 +358,28 @@ impl RowsView { None } + pub fn get_column_values_from_selection( + &self, + indices: Option<&Vec>, + ) -> Option<(usize, String)> { + if let Some(column_index) = self.selection.column.index() { + let filtered_index = self.cols_offset.get_filtered_column_index(column_index) as usize; + if let Some(header) = self.headers.get(filtered_index) { + let origin_index = header.origin_index; + let values = if let Some(indices) = indices { + self.reader + .get_column_values_for_indices(origin_index, indices) + } else { + self.reader.get_column_values(origin_index) + }; + if let Ok(values) = values { + return Some((column_index as usize, values.join("\n"))); + } + } + } + None + } + pub fn get_row_value(&self) -> Option<(usize, String)> { if let Some(row_index) = self.selection.row.index() && let Some(row) = self.rows().get(row_index as usize) From 995c399c619cfc7666f2d22de042ca6ca404e6dc Mon Sep 17 00:00:00 2001 From: Giant Date: Tue, 2 Dec 2025 16:09:15 +0100 Subject: [PATCH 2/7] better messages --- src/app.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 76f1f5e..1a4c8f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -608,14 +608,13 @@ impl App { Ok(_) => self .transient_message .replace(format!("Copied column {} to clipboard", column_index)), - Err(e) => self.transient_message.replace(format!( - "Failed to copy column {} to clipboard: {}", - column_index, e - )), + Err(e) => self + .transient_message + .replace(format!("Failed to copy column to clipboard: {}", e)), } } else { self.transient_message - .replace(format!("Trying to copy a column")) + .replace(format!("Could not copy column")) }; } } From 5535eb003b41e61e7df87c811b85813f2df6380a Mon Sep 17 00:00:00 2001 From: Giant Date: Tue, 2 Dec 2025 17:51:04 +0100 Subject: [PATCH 3/7] set limit on number of rows copied to clipboard to prevent performance issues --- src/app.rs | 30 +++++++++++++++++++++++++----- src/csv.rs | 11 ++++++++++- src/view.rs | 19 +++++++++++++++---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1a4c8f5..c333bd7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -600,14 +600,34 @@ impl App { } else { None }; - if let Some((column_index, column_values)) = self + let limit = 10000; + if let Some((column_index, column_values, count)) = self .rows_view - .get_column_values_from_selection(indices.as_ref()) + .get_column_values_from_selection(indices.as_ref(), Some(limit)) { + let filtered_index = self + .rows_view + .cols_offset() + .get_filtered_column_index(column_index as u64) + as usize; + let column_name = self + .rows_view + .get_column_name_from_local_index(filtered_index); match self.clipboard.as_mut().map(|c| c.set_text(&column_values)) { - Ok(_) => self - .transient_message - .replace(format!("Copied column {} to clipboard", column_index)), + Ok(_) => { + let msg = if count >= limit { + format!( + "Copied first {} rows of column \"{}\" to clipboard", + count, column_name + ) + } else { + format!( + "Copied {} rows of column \"{}\" to clipboard", + count, column_name + ) + }; + self.transient_message.replace(msg) + } Err(e) => self .transient_message .replace(format!("Failed to copy column to clipboard: {}", e)), diff --git a/src/csv.rs b/src/csv.rs index bd8b7f3..b0e5d98 100644 --- a/src/csv.rs +++ b/src/csv.rs @@ -331,10 +331,19 @@ impl CsvLensReader { } } - pub fn get_column_values(&self, column_index: usize) -> CsvlensResult> { + pub fn get_column_values( + &self, + column_index: usize, + limit: Option, + ) -> CsvlensResult> { let mut reader = self.config.new_reader()?; let mut values = vec![]; for result in reader.records() { + if let Some(l) = limit { + if values.len() >= l { + break; + } + } let record = result?; if let Some(field) = record.get(column_index) { values.push(field.to_string()); diff --git a/src/view.rs b/src/view.rs index 054abda..96f4281 100644 --- a/src/view.rs +++ b/src/view.rs @@ -361,19 +361,30 @@ impl RowsView { pub fn get_column_values_from_selection( &self, indices: Option<&Vec>, - ) -> Option<(usize, String)> { + limit: Option, + ) -> Option<(usize, String, usize)> { if let Some(column_index) = self.selection.column.index() { let filtered_index = self.cols_offset.get_filtered_column_index(column_index) as usize; if let Some(header) = self.headers.get(filtered_index) { let origin_index = header.origin_index; let values = if let Some(indices) = indices { + let indices_to_use = if let Some(l) = limit { + if indices.len() > l { + &indices[0..l] + } else { + indices.as_slice() + } + } else { + indices.as_slice() + }; self.reader - .get_column_values_for_indices(origin_index, indices) + .get_column_values_for_indices(origin_index, indices_to_use) } else { - self.reader.get_column_values(origin_index) + self.reader.get_column_values(origin_index, limit) }; if let Ok(values) = values { - return Some((column_index as usize, values.join("\n"))); + let count = values.len(); + return Some((column_index as usize, values.join("\n"), count)); } } } From 4871df911c85975ddf1d91b7021c566c3a329b16 Mon Sep 17 00:00:00 2001 From: Giant Date: Tue, 2 Dec 2025 19:21:35 +0100 Subject: [PATCH 4/7] make clipboard size limit cli option --- README.md | 4 +++- src/app.rs | 10 ++++++++-- src/help.rs | 2 +- src/runner.rs | 7 +++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3f15494..271b4ac 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,15 @@ Key | Action `Ctrl + j` | Same as above, but sort by natural ordering (e.g. "file2" < "file10") `#` (in Cell mode) | Find and highlight rows like the selected cell `@` (in Cell mode) | Filter rows like the selected cell -`y` | Copy the selected row or cell to clipboard +`y` | Copy the selected row, cell or column to clipboard `Enter` (in Cell mode) | Print the selected cell to stdout and exit `-S` | Toggle line wrapping `-W` | Toggle line wrapping by words `f` | Freeze this number of columns from the left `r` | Reset to default view (clear all filters and custom column widths) `H` (or `?`) | Display help +`m` | Toggle line mark +`M` | Reset line marks `q` | Exit ### Optional parameters diff --git a/src/app.rs b/src/app.rs index c333bd7..5b94504 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,8 @@ use std::cmp::min; use std::sync::Arc; use std::time::{Duration, Instant}; +const DEFAULT_CLIPBOARD_LIMIT: usize = 10000; + fn get_offsets_to_make_visible( found_record: &find::FoundEntry, rows_view: &view::RowsView, @@ -182,6 +184,7 @@ pub struct App { sorter: Option>, sort_order: SortOrder, wrap_mode: WrapMode, + clipboard_limit: Option, #[cfg(feature = "clipboard")] clipboard: Result, } @@ -204,6 +207,7 @@ impl App { prompt: Option, wrap_mode: Option, auto_reload: bool, + clipboard_limit: Option, ) -> CsvlensResult { let watcher = if auto_reload { Some(Arc::new(Watcher::new(filename)?)) @@ -280,6 +284,7 @@ impl App { sorter: None, sort_order: SortOrder::Ascending, wrap_mode: WrapMode::default(), + clipboard_limit, #[cfg(feature = "clipboard")] clipboard, }; @@ -600,7 +605,7 @@ impl App { } else { None }; - let limit = 10000; + let limit = self.clipboard_limit.unwrap_or(DEFAULT_CLIPBOARD_LIMIT); if let Some((column_index, column_values, count)) = self .rows_view .get_column_values_from_selection(indices.as_ref(), Some(limit)) @@ -615,7 +620,7 @@ impl App { .get_column_name_from_local_index(filtered_index); match self.clipboard.as_mut().map(|c| c.set_text(&column_values)) { Ok(_) => { - let msg = if count >= limit { + let msg = if count == limit { format!( "Copied first {} rows of column \"{}\" to clipboard", count, column_name @@ -1147,6 +1152,7 @@ mod tests { self.prompt, self.wrap_mode, false, + None, ) } diff --git a/src/help.rs b/src/help.rs index ac53ac5..31451d4 100644 --- a/src/help.rs +++ b/src/help.rs @@ -43,7 +43,7 @@ Shift + ↓ (or J) : Sort rows by the selected column (auto by type: nume Ctrl + J : Sort rows by the selected column (natural; e.g. \"file2\" < \"file10\") # (in Cell mode) : Find and highlight rows like the selected cell @ (in Cell mode) : Filter rows like the selected cell -y : Copy the selected row or cell to clipboard +y : Copy the selected row, cell or column to clipboard Enter (in Cell mode) : Print the selected cell to stdout and exit # Other options diff --git a/src/runner.rs b/src/runner.rs index fdd52c2..d6472be 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -110,6 +110,10 @@ struct Args { #[clap(long)] pub auto_reload: bool, + /// Limit the number of rows to copy to clipboard + #[clap(long)] + pub clipboard_limit: Option, + /// Show stats for debugging #[clap(long)] debug: bool, @@ -159,6 +163,7 @@ impl From for CsvlensOptions { prompt: args.prompt, wrap_mode: Args::get_wrap_mode(args.wrap, args.wrap_chars, args.wrap_words), auto_reload: args.auto_reload, + clipboard_limit: args.clipboard_limit, } } } @@ -182,6 +187,7 @@ pub struct CsvlensOptions { pub prompt: Option, pub wrap_mode: Option, pub auto_reload: bool, + pub clipboard_limit: Option, } struct AppRunner { @@ -274,6 +280,7 @@ pub fn run_csvlens_with_options(options: CsvlensOptions) -> CsvlensResult