Skip to content

Commit d68f880

Browse files
feat(yank): copy formatted query to clipboard on Wayland (wl-copy) (#222)
* feat(yank): copy formatted query to clipboard on Wayland (wl-copy) and X11 (xclip) Add clipboard support for yank (wl-copy / xclip) * feat: Use arboard. * feat: Add Modal * pin test runner rust version * pin builder rust version --------- Co-authored-by: achristmascarl <[email protected]>
1 parent 98b7cf3 commit d68f880

File tree

8 files changed

+205
-2
lines changed

8 files changed

+205
-2
lines changed

.github/workflows/cd.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ jobs:
9898
env:
9999
RUSTFLAGS: ${{ matrix.rust-crossflags }}
100100
with:
101+
toolchain: 1.88
101102
command: build
102103
target: ${{ matrix.target }}
103104
args: "--release --features ${{ matrix.features }}"

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ jobs:
5252
- name: Install Rust toolchain
5353
uses: dtolnay/rust-toolchain@stable
5454
with:
55+
toolchain: 1.88
5556
targets: ${{ matrix.target }}
5657
- uses: Swatinem/rust-cache@v2
5758
- name: Run tests (cross)

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ flamegraph*
66
*.data
77
test.tape
88
dev/*.sqlite3
9+
*.env

src/action.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ pub enum Action {
4444
RequestExportData(i64),
4545
ExportData(ExportFormat),
4646
ExportDataFinished,
47+
RequestYankAll(i64),
48+
YankAll,
4749
RequestSaveFavorite(Vec<String>),
4850
SaveFavorite(String, Vec<String>),
4951
DeleteFavorite(String),

src/app.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use crate::{
3030
focus::Focus,
3131
popups::{
3232
PopUp, PopUpPayload, confirm_bypass::ConfirmBypass, confirm_export::ConfirmExport, confirm_query::ConfirmQuery,
33-
confirm_tx::ConfirmTx, exporting::Exporting, name_favorite::NameFavorite,
33+
confirm_tx::ConfirmTx, confirm_yank::ConfirmYank, exporting::Exporting, name_favorite::NameFavorite,
3434
},
3535
tui,
3636
ui::center,
@@ -257,6 +257,13 @@ impl App {
257257
self.set_focus(Focus::Data);
258258
}
259259
},
260+
Some(PopUpPayload::ConfirmYank(confirmed)) => {
261+
if confirmed {
262+
action_tx.send(Action::YankAll)?;
263+
} else {
264+
self.set_focus(Focus::Data);
265+
}
266+
},
260267
Some(PopUpPayload::Cancel) => {
261268
self.last_focused_component();
262269
},
@@ -462,13 +469,17 @@ impl App {
462469
},
463470
);
464471
}
472+
self.set_focus(Focus::Data);
465473
},
466474
Action::RequestExportData(row_count) => {
467475
self.set_popup(Box::new(ConfirmExport::new(*row_count)));
468476
},
469477
Action::ExportDataFinished => {
470478
self.set_focus(Focus::Data);
471479
},
480+
Action::RequestYankAll(row_count) => {
481+
self.set_popup(Box::new(ConfirmYank::new(*row_count)));
482+
},
472483
_ => {},
473484
}
474485
if !action_consumed {
@@ -618,7 +629,7 @@ impl App {
618629
Focus::Favorites =>
619630
"[j|↓] down [k|↑] up [y] copy query [I] edit query [D] delete entry [/] search [<esc>] clear search",
620631
Focus::Data if !self.state.query_task_running =>
621-
"[P] export [j|↓] next row [k|↑] prev row [w|e] next col [b] prev col [v] select field [V] select row [y] copy [g] top [G] bottom [0] first col [$] last col",
632+
"[P] export [j|↓] next row [k|↑] prev row [w|e] next col [b] prev col [v] select field [V] select row [y] copy [Y] copy all [g] top [G] bottom [0] first col [$] last col",
622633
Focus::PopUp => "[<esc>] cancel",
623634
_ => "",
624635
}

src/components/data.rs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::collections::VecDeque;
2+
13
use color_eyre::eyre::{self, Result};
24
use crossterm::event::{KeyEvent, MouseEventKind};
35
use csv::Writer;
@@ -366,6 +368,11 @@ impl Component for Data<'_> {
366368
self.scrollable.transition_selection_mode(Some(SelectionMode::Copied));
367369
}
368370
},
371+
Input { key: Key::Char('Y'), .. } => {
372+
if let DataState::HasResults(rows) = &self.data_state {
373+
self.command_tx.clone().unwrap().send(Action::RequestYankAll(rows.rows.len() as i64))?;
374+
}
375+
},
369376
Input { key: Key::Esc, .. } => {
370377
self.scrollable.transition_selection_mode(None);
371378
},
@@ -389,6 +396,13 @@ impl Component for Data<'_> {
389396
}
390397
writer.flush()?;
391398
self.command_tx.clone().unwrap().send(Action::ExportDataFinished)?;
399+
} else if let Action::YankAll = action {
400+
let DataState::HasResults(rows) = &self.data_state else {
401+
return Ok(None);
402+
};
403+
let table_for_yank = TableForYank::new(rows, app_state).yank();
404+
self.command_tx.clone().unwrap().send(Action::CopyData(table_for_yank))?;
405+
self.scrollable.transition_selection_mode(Some(SelectionMode::Copied));
392406
}
393407
Ok(None)
394408
}
@@ -530,3 +544,138 @@ impl Component for Data<'_> {
530544
Ok(())
531545
}
532546
}
547+
548+
struct TableForYank {
549+
sql: Vec<String>,
550+
table: Vec<VecDeque<String>>,
551+
}
552+
553+
impl TableForYank {
554+
fn new(rows: &Rows, app_state: &AppState) -> Self {
555+
let sql = app_state.history.first().expect("expected the last SQL query in history").query_lines.clone();
556+
557+
let headers: &Vec<String> = &rows.headers.iter().map(|h| h.name.clone()).collect();
558+
let rows = &rows.rows;
559+
560+
let table = Self::to_columns(headers, rows);
561+
562+
Self { sql, table }
563+
}
564+
565+
fn yank(&mut self) -> String {
566+
let last_index = self.table.len() - 1;
567+
self.table.iter_mut().enumerate().for_each(|(index, col)| Self::format_column(col, index, last_index));
568+
569+
let mut buff = String::new();
570+
571+
for statement in &self.sql {
572+
buff.push_str(statement);
573+
buff.push('\n');
574+
}
575+
576+
buff.push('\n');
577+
578+
while let Some(col) = self.table.first() {
579+
if col.is_empty() {
580+
break;
581+
}
582+
583+
for col in &mut self.table {
584+
if let Some(cell) = col.pop_front() {
585+
buff.push_str(&cell);
586+
}
587+
}
588+
buff.push('\n');
589+
}
590+
591+
buff
592+
}
593+
594+
fn format_column(col: &mut VecDeque<String>, index: usize, last_index: usize) {
595+
let width = col.iter().map(|s| s.len()).max().unwrap_or(1) + 1;
596+
597+
let format_cell = |s: &str| {
598+
let prefix = if index == 0 { " " } else { "| " };
599+
let padding = if index == last_index { " ".repeat(0) } else { " ".repeat(width.saturating_sub(s.len())) };
600+
format!("{prefix}{s}{padding}")
601+
};
602+
603+
col.iter_mut().for_each(|s| *s = format_cell(s));
604+
605+
if let Some(header) = col.pop_front() {
606+
let div = if index == 0 { "-".repeat(width + 1) } else { format!("+{}", "-".repeat(width + 1)) };
607+
col.push_front(div);
608+
col.push_front(header);
609+
}
610+
}
611+
612+
fn to_columns(headers: &[String], rows: &[Vec<String>]) -> Vec<VecDeque<String>> {
613+
headers
614+
.iter()
615+
.enumerate()
616+
.map(|(i, h)| {
617+
let mut col: VecDeque<String> = VecDeque::from([h.clone()]);
618+
rows.iter().filter_map(|row| row.get(i)).cloned().for_each(|v| col.push_back(v));
619+
col
620+
})
621+
.collect()
622+
}
623+
}
624+
625+
#[cfg(test)]
626+
mod yank {
627+
628+
use std::collections::VecDeque;
629+
630+
use crate::components::data::TableForYank;
631+
632+
#[test]
633+
fn to_columns_is_works() {
634+
let headers = vec!["id".to_string(), "name".to_string(), "age".to_string()];
635+
let rows = vec![
636+
vec!["id1".to_string(), "name1".to_string(), "age1".to_string()],
637+
vec!["id2".to_string(), "name2".to_string(), "age2".to_string()],
638+
vec!["id3".to_string(), "name3".to_string(), "age3".to_string()],
639+
];
640+
641+
let result = TableForYank::to_columns(&headers, &rows);
642+
643+
let expected = vec![
644+
VecDeque::from(["id".to_string(), "id1".to_string(), "id2".to_string(), "id3".to_string()]),
645+
VecDeque::from(["name".to_string(), "name1".to_string(), "name2".to_string(), "name3".to_string()]),
646+
VecDeque::from(["age".to_string(), "age1".to_string(), "age2".to_string(), "age3".to_string()]),
647+
];
648+
649+
assert_eq!(expected, result)
650+
}
651+
652+
#[test]
653+
fn yank_is_works() {
654+
let headers = vec!["id".to_string(), "name".to_string(), "age".to_string()];
655+
let rows = vec![
656+
vec!["id1".to_string(), "name1".to_string(), "age1".to_string()],
657+
vec!["id2".to_string(), "name2".to_string(), "age2".to_string()],
658+
vec!["id3".to_string(), "name3".to_string(), "age3".to_string()],
659+
];
660+
661+
let mut data_to_yank = TableForYank {
662+
sql: vec!["select".to_string(), "*".to_string(), "from".to_string(), "something".to_string()],
663+
table: TableForYank::to_columns(&headers, &rows),
664+
};
665+
666+
let result = data_to_yank.yank();
667+
668+
let expected = "\
669+
select
670+
*
671+
from
672+
something
673+
674+
id | name | age
675+
-----+-------+------
676+
id1 | name1 | age1
677+
id2 | name2 | age2
678+
id3 | name3 | age3
679+
";
680+
}
681+
}

src/popups/confirm_yank.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
use crossterm::event::KeyCode;
2+
3+
use super::{PopUp, PopUpPayload};
4+
5+
#[derive(Debug)]
6+
pub struct ConfirmYank {
7+
row_count: i64,
8+
}
9+
10+
impl ConfirmYank {
11+
pub fn new(row_count: i64) -> Self {
12+
Self { row_count }
13+
}
14+
}
15+
16+
impl PopUp for ConfirmYank {
17+
fn handle_key_events(
18+
&mut self,
19+
key: crossterm::event::KeyEvent,
20+
app_state: &mut crate::app::AppState,
21+
) -> color_eyre::eyre::Result<Option<PopUpPayload>> {
22+
match key.code {
23+
KeyCode::Char('Y') => Ok(Some(PopUpPayload::ConfirmYank(true))),
24+
KeyCode::Char('N') | KeyCode::Esc => Ok(Some(PopUpPayload::ConfirmYank(false))),
25+
_ => Ok(None),
26+
}
27+
}
28+
29+
fn get_cta_text(&self, app_state: &crate::app::AppState) -> String {
30+
format!("Are you sure you want to yank {} rows? Copying too many rows may cause the app to hang.", self.row_count,)
31+
}
32+
33+
fn get_actions_text(&self, app_state: &crate::app::AppState) -> String {
34+
"[Y]es to confirm | [N]o to cancel".to_string()
35+
}
36+
}

src/popups/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod confirm_bypass;
88
pub mod confirm_export;
99
pub mod confirm_query;
1010
pub mod confirm_tx;
11+
pub mod confirm_yank;
1112
pub mod exporting;
1213
pub mod name_favorite;
1314

@@ -24,6 +25,7 @@ pub enum PopUpPayload {
2425
ConfirmQuery(String),
2526
ConfirmBypass(String),
2627
ConfirmExport(bool),
28+
ConfirmYank(bool),
2729
NamedFavorite(String, Vec<String>),
2830
}
2931

0 commit comments

Comments
 (0)