diff --git a/TODO.md b/TODO.md index ffb1995..9ba6d1a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,4 @@ -- [ ] add selection + copy support in the rows panel. +- [x] add selection + copy support in the rows panel. - should be able to select/highlight an arbitrary rectangle (like vim visual block basically) - should be able to copy selection - copied data should be formatted nicely before inserting into the clipboard (system clipboard) diff --git a/src/app.rs b/src/app.rs index f4ae2d9..c3834cc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -29,6 +29,9 @@ pub struct App { page: Page, page_index: usize, rows_state: TableState, + selection_anchor: Option<(usize, usize)>, + copy_headers: Option, + copy_status: Option, schema_state: TableState, column_offset: usize, show_schema: bool, @@ -55,6 +58,9 @@ impl App { }, page_index: 0, rows_state: TableState::default(), + selection_anchor: None, + copy_headers: None, + copy_status: None, schema_state: TableState::default(), column_offset: 0, show_schema: false, @@ -94,13 +100,127 @@ impl App { self.query.position() } + pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool { + let pending = self.copy_headers.take(); + if self.show_schema { + return false; + } + if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL) + { + return false; + } + if let Some(headers) = pending { + match key.code { + KeyCode::Char(format @ ('m' | 'c')) => { + if let Some(text) = self.selection_text(headers, format == 'm') { + match copy_to_clipboard(&text) { + Ok(()) => { + self.error = None; + self.copy_status = Some(format!( + " Copied {} {} headers ", + if format == 'm' { "Markdown" } else { "CSV" }, + if headers { "with" } else { "without" }, + )); + } + Err(error) => { + self.error = Some(format!("Clipboard: {}", error_text(error.as_ref()))); + } + } + } + return true; + } + KeyCode::Esc => return true, + _ => {} + } + } + match key.code { + KeyCode::Char('v') => { + self.copy_status = None; + self.selection_anchor = if self.selection_anchor.is_some() { + None + } else { + self.rows_state.selected_cell() + }; + true + } + KeyCode::Char(character @ ('y' | 'Y')) => { + self.copy_status = None; + if self.selection_bounds().is_some() { + self.copy_headers = Some(character == 'y'); + } + true + } + KeyCode::Esc if self.selection_anchor.is_some() => { + self.selection_anchor = None; + self.copy_status = None; + true + } + _ => false, + } + } + + fn clear_selection(&mut self) { + self.selection_anchor = None; + self.copy_headers = None; + self.copy_status = None; + } + + fn selection_bounds(&self) -> Option<(usize, usize, usize, usize)> { + if self.show_schema { + return None; + } + let (row, column) = self.rows_state.selected_cell()?; + let (anchor_row, anchor_column) = self.selection_anchor.unwrap_or((row, column)); + Some(( + row.min(anchor_row), + row.max(anchor_row), + column.min(anchor_column), + column.max(anchor_column), + )) + } + + fn selection_text(&self, headers: bool, markdown: bool) -> Option { + let (top, bottom, left, right) = self.selection_bounds()?; + let page = self.query_result.as_ref().map_or(&self.page, |result| &result.page); + let rows = page.rows.get(top..=bottom)?; + let mut output = String::new(); + if headers { + append_record( + &mut output, + (left..=right).map(|column| { + self.query_result.as_ref().map_or_else( + || self.columns[column].label.as_str(), + |result| result.headers[column].as_str(), + ) + }), + markdown, + ); + } else if markdown { + append_record(&mut output, (left..=right).map(|_| ""), true); + } + if markdown { + append_record(&mut output, (left..=right).map(|_| "---"), true); + } + for row in rows { + append_record( + &mut output, + row.get(left..=right)?.iter().map(|value| value.as_deref().unwrap_or("NULL")), + markdown, + ); + } + Some(output) + } + pub(crate) fn handle_key(&mut self, key: KeyEvent) { + self.copy_status = None; match key.code { KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX), KeyCode::Char('s') if self.query_result.is_none() => { + self.clear_selection(); self.show_schema = !self.show_schema; self.column_offset = 0; } @@ -204,6 +324,7 @@ impl App { fn execute_query(&mut self) { match self.db.query(&self.query.value) { Ok(result) => { + self.clear_selection(); self.error = None; if self.db.access_mode() == crate::db::AccessMode::ReadWrite { self.refresh(); @@ -254,6 +375,7 @@ impl App { } pub(crate) fn open_relation(&mut self, index: Option) { + self.clear_selection(); self.active = index; self.query_result = None; self.columns.clear(); @@ -286,6 +408,7 @@ impl App { // ponytail: synchronous reads block input; use a worker if responsiveness matters. match self.db.page(&self.relations[active], &self.columns, index) { Ok(page) => { + self.clear_selection(); self.rows_state = TableState::default() .with_selected((!page.rows.is_empty()).then_some(0)) .with_selected_column( @@ -340,7 +463,14 @@ impl App { focus == Focus::Query, Some(sql::text(&self.query.value, &self.query_highlights)), ); - let title = if self.query_result.is_some() { + let title = if let Some(headers) = self.copy_headers { + if headers { " Copy with headers · m Markdown / c CSV " } + else { " Copy without headers · m Markdown / c CSV " } + } else if self.selection_anchor.is_some() { + " VISUAL BLOCK · ym/yc with headers · Ym/Yc without " + } else if let Some(status) = &self.copy_status { + status.as_str() + } else if self.query_result.is_some() { " Results " } else if self.show_schema { " Schema · s for rows " @@ -474,18 +604,30 @@ impl App { |result| result.headers[index].as_str(), ) }); - let rows = page.rows.iter().map(|row| { + let bounds = self.selection_anchor.and_then(|_| self.selection_bounds()); + let rows = page.rows.iter().enumerate().map(|(row_index, row)| { Row::new( row.iter() + .enumerate() .skip(self.column_offset) .take(visible) - .map(|value| match value { - Some(value) => Cell::from(value.as_str()), - None => Cell::from("NULL").style( - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::ITALIC), - ), + .map(|(column_index, value)| { + let cell = match value { + Some(value) => Cell::from(value.as_str()), + None => Cell::from("NULL").style( + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ), + }; + if bounds.is_some_and(|(top, bottom, left, right)| { + (top..=bottom).contains(&row_index) + && (left..=right).contains(&column_index) + }) { + cell.style(selected_style()) + } else { + cell + } }), ) }); @@ -507,6 +649,76 @@ impl App { } } +fn append_record<'a>( + output: &mut String, + values: impl Iterator, + markdown: bool, +) { + if markdown { + output.push('|'); + } + for (index, value) in values.enumerate() { + if markdown { + output.push(' '); + for character in value.chars() { + match character { + '&' => output.push_str("&"), + '<' => output.push_str("<"), + '>' => output.push_str(">"), + '\\' | '|' | '`' | '*' | '_' | '[' | ']' => { + output.push('\\'); + output.push(character); + } + _ => output.push(character), + } + } + output.push_str(" |"); + } else { + if index > 0 { + output.push(','); + } + output.push('"'); + for character in value.chars() { + if character == '"' { + output.push('"'); + } + output.push(character); + } + output.push('"'); + } + } + output.push_str(if markdown { "\n" } else { "\r\n" }); +} + +fn copy_to_clipboard(text: &str) -> Result<()> { + use std::{io::Write, process::{Command, Stdio}}; + + let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") { + ("pbcopy", &[]) + } else if cfg!(target_os = "windows") { + ("powershell.exe", &["-NoProfile", "-Command", + "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())"]) + } else if std::env::var_os("WAYLAND_DISPLAY").is_some() { + ("wl-copy", &[]) + } else { + ("xclip", &["-selection", "clipboard"]) + }; + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("could not start {program}: {error}"))?; + let written = child.stdin.take().unwrap().write_all(text.as_bytes()); + let status = child.wait()?; + if !status.success() { + return Err(format!("{program} failed ({status})").into()); + } + written?; + Ok(()) +} + fn panel(title: &str, focused: bool) -> Block<'_> { Block::bordered() .title(title) @@ -543,6 +755,90 @@ fn moved_index(selected: Option, count: usize, delta: isize) -> Option crate::Result<()> { + use super::{App, Focus}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend, style::Color}; + + let file = tempfile::NamedTempFile::new()?; + rusqlite::Connection::open(file.path())?.execute_batch( + "CREATE TABLE cells (a, b, c, d); + INSERT INTO cells VALUES + ('outside', 'a|b', 'say \"hi\",界', 'outside'), + ('outside', NULL, '<&>\\tail', 'outside'), + ('outside', 'outside', 'outside', 'outside'); + WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101) + INSERT INTO cells SELECT x,x,x,x FROM n;", + )?; + let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); + // Anchor at the bottom right, extend up and left across a scrolled viewport. + for character in ['j', 'l', 'l'] { + app.handle_key(key(character)); + } + let mut terminal = Terminal::new(TestBackend::new(60, 20))?; + terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?; + assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL))); + app.handle_key(key('k')); + app.handle_key(key('h')); + terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Rows))?; + let buffer = terminal.backend().buffer(); + assert_eq!(app.column_offset, 1); + for position in [(1, 12), (22, 12), (1, 13), (22, 13)] { + assert_eq!(buffer[position].bg, Color::Blue); + } + assert_ne!(buffer[(1, 14)].bg, Color::Blue); + assert_eq!( + app.selection_text(true, true).as_deref(), + Some("| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n") + ); + assert_eq!( + app.selection_text(false, true).as_deref(), + Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n") + ); + assert_eq!( + app.selection_text(true, false).as_deref(), + Some("\"b\",\"c\"\r\n\"a|b\",\"say \"\"hi\"\",界\"\r\n\"NULL\",\"<&>\\tail\"\r\n") + ); + assert_eq!( + app.selection_text(false, false).as_deref(), + Some("\"a|b\",\"say \"\"hi\"\",界\"\r\n\"NULL\",\"<&>\\tail\"\r\n") + ); + assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); + assert_eq!(app.selection_text(false, false).as_deref(), Some("\"a|b\"\r\n")); + // A prefix cancels on focus changes rather than capturing a later key. + app.handle_rows_command(key('y')); + assert!(!app.handle_rows_command(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))); + assert!(app.copy_headers.is_none()); + app.handle_rows_command(key('v')); + app.handle_key(key('n')); + assert!(app.selection_anchor.is_none()); + assert_eq!(app.selection_text(false, false).as_deref(), Some("\"101\"\r\n")); + app.handle_rows_command(key('v')); + app.handle_key(key('s')); + assert!(app.selection_anchor.is_none()); + assert!(app.selection_text(true, true).is_none()); + app.handle_key(key('s')); + app.handle_rows_command(key('v')); + app.query.value = "SELECT 42 AS answer".into(); + app.execute_query(); + assert!(app.selection_anchor.is_none()); + assert_eq!(app.selection_text(true, false).as_deref(), Some("\"answer\"\r\n\"42\"\r\n")); + app.handle_rows_command(key('v')); + app.query.value = "SELECT missing FROM nowhere".into(); + app.execute_query(); + assert_eq!(app.selection_text(false, false).as_deref(), Some("\"42\"\r\n")); + assert!(app.selection_anchor.is_some()); + app.open_relation(Some(0)); + assert!(app.selection_anchor.is_none()); + app.query.value = "SELECT * FROM cells WHERE 0".into(); + app.execute_query(); + app.handle_rows_command(key('v')); + assert!(app.selection_text(true, true).is_none()); + Ok(()) + } + #[test] fn navigation_clamps_to_available_items() { assert_eq!(moved_index(None, 0, 1), None); diff --git a/src/connection.rs b/src/connection.rs index 3ffa38d..6a463bc 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -221,6 +221,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } else { match key.code { + _ if focus == Focus::Rows + && browser.as_mut().is_some_and(|app| app.handle_rows_command(key)) => {} KeyCode::Char('?') if focus != Focus::Query || !browser.as_ref().is_some_and(App::query_insert_mode) => @@ -423,6 +425,16 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { s Toggle rows / column schema\n\ n/p, PgDn/PgUp Next / previous 100-row relation page\n\ r Refresh relations and active relation\n\n\ + Rows / query results\n\ + v / Ctrl-V Toggle rectangular block; hjkl/arrows extend it\n\ + ym / yc Copy Markdown / CSV with column headers\n\ + Ym / Yc Copy Markdown / CSV without column names\n\ + Esc Cancel pending copy or block before leaving rows\n\ + With no block, copy uses the current cell. Blocks stay within the loaded page.\n\ + Paging, refresh, schema/relation changes, and successful queries clear blocks.\n\ + Copy uses full displayed values; NULL/control escapes are preserved.\n\ + Headerless Markdown has blank headings. CSV quotes fields with CRLF endings.\n\ + Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\n\ Query editor\n\ i/a/I/A Enter Insert mode (at/after/start/end)\n\ hjkl / arrows Move cursor in Normal mode\n\ diff --git a/src/main.rs b/src/main.rs index 690ef0d..b25d709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,6 +72,16 @@ Navigation: s Toggle rows and column schema Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too) n/p, PgDn/Up Next / previous 100-row relation page + v / Ctrl-V Toggle rectangular selection in rows/results; move with hjkl/arrows + ym / yc Copy the block (or current cell) as Markdown / CSV with column headers + Ym / Yc Copy as Markdown / CSV without column names + Esc Cancel a pending copy or block selection before returning to explorer + Blocks cover the current loaded page, including cells scrolled out of view. + Paging, refreshing, opening a relation, schema changes, and successful queries clear blocks. + Copies use full displayed cell values (NULL and escaped control characters included). + Headerless Markdown uses blank headings; CSV quotes fields and uses CRLF line endings. + Clipboard tools: pbcopy on macOS, PowerShell on Windows, wl-copy on Wayland, + or xclip on X11. A missing/failing tool reports an error without clearing the block. c Add a configured database in a floating window i Show configured database info (Esc/Enter closes; passwords omitted) Works on a saved database entry or any of its table/view children.