feat: select and copy rectangular row ranges
This commit is contained in:
@@ -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 select/highlight an arbitrary rectangle (like vim visual block basically)
|
||||||
- should be able to copy selection
|
- should be able to copy selection
|
||||||
- copied data should be formatted nicely before inserting into the clipboard (system clipboard)
|
- copied data should be formatted nicely before inserting into the clipboard (system clipboard)
|
||||||
|
|||||||
+299
-3
@@ -29,6 +29,9 @@ pub struct App {
|
|||||||
page: Page,
|
page: Page,
|
||||||
page_index: usize,
|
page_index: usize,
|
||||||
rows_state: TableState,
|
rows_state: TableState,
|
||||||
|
selection_anchor: Option<(usize, usize)>,
|
||||||
|
copy_headers: Option<bool>,
|
||||||
|
copy_status: Option<String>,
|
||||||
schema_state: TableState,
|
schema_state: TableState,
|
||||||
column_offset: usize,
|
column_offset: usize,
|
||||||
show_schema: bool,
|
show_schema: bool,
|
||||||
@@ -55,6 +58,9 @@ impl App {
|
|||||||
},
|
},
|
||||||
page_index: 0,
|
page_index: 0,
|
||||||
rows_state: TableState::default(),
|
rows_state: TableState::default(),
|
||||||
|
selection_anchor: None,
|
||||||
|
copy_headers: None,
|
||||||
|
copy_status: None,
|
||||||
schema_state: TableState::default(),
|
schema_state: TableState::default(),
|
||||||
column_offset: 0,
|
column_offset: 0,
|
||||||
show_schema: false,
|
show_schema: false,
|
||||||
@@ -94,13 +100,127 @@ impl App {
|
|||||||
self.query.position()
|
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<String> {
|
||||||
|
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) {
|
pub(crate) fn handle_key(&mut self, key: KeyEvent) {
|
||||||
|
self.copy_status = None;
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
||||||
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
||||||
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX),
|
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX),
|
||||||
KeyCode::Char('s') if self.query_result.is_none() => {
|
KeyCode::Char('s') if self.query_result.is_none() => {
|
||||||
|
self.clear_selection();
|
||||||
self.show_schema = !self.show_schema;
|
self.show_schema = !self.show_schema;
|
||||||
self.column_offset = 0;
|
self.column_offset = 0;
|
||||||
}
|
}
|
||||||
@@ -204,6 +324,7 @@ impl App {
|
|||||||
fn execute_query(&mut self) {
|
fn execute_query(&mut self) {
|
||||||
match self.db.query(&self.query.value) {
|
match self.db.query(&self.query.value) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
self.clear_selection();
|
||||||
self.error = None;
|
self.error = None;
|
||||||
if self.db.access_mode() == crate::db::AccessMode::ReadWrite {
|
if self.db.access_mode() == crate::db::AccessMode::ReadWrite {
|
||||||
self.refresh();
|
self.refresh();
|
||||||
@@ -254,6 +375,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
||||||
|
self.clear_selection();
|
||||||
self.active = index;
|
self.active = index;
|
||||||
self.query_result = None;
|
self.query_result = None;
|
||||||
self.columns.clear();
|
self.columns.clear();
|
||||||
@@ -286,6 +408,7 @@ impl App {
|
|||||||
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
|
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
|
||||||
match self.db.page(&self.relations[active], &self.columns, index) {
|
match self.db.page(&self.relations[active], &self.columns, index) {
|
||||||
Ok(page) => {
|
Ok(page) => {
|
||||||
|
self.clear_selection();
|
||||||
self.rows_state = TableState::default()
|
self.rows_state = TableState::default()
|
||||||
.with_selected((!page.rows.is_empty()).then_some(0))
|
.with_selected((!page.rows.is_empty()).then_some(0))
|
||||||
.with_selected_column(
|
.with_selected_column(
|
||||||
@@ -340,7 +463,14 @@ impl App {
|
|||||||
focus == Focus::Query,
|
focus == Focus::Query,
|
||||||
Some(sql::text(&self.query.value, &self.query_highlights)),
|
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 "
|
" Results "
|
||||||
} else if self.show_schema {
|
} else if self.show_schema {
|
||||||
" Schema · s for rows "
|
" Schema · s for rows "
|
||||||
@@ -474,18 +604,30 @@ impl App {
|
|||||||
|result| result.headers[index].as_str(),
|
|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::new(
|
||||||
row.iter()
|
row.iter()
|
||||||
|
.enumerate()
|
||||||
.skip(self.column_offset)
|
.skip(self.column_offset)
|
||||||
.take(visible)
|
.take(visible)
|
||||||
.map(|value| match value {
|
.map(|(column_index, value)| {
|
||||||
|
let cell = match value {
|
||||||
Some(value) => Cell::from(value.as_str()),
|
Some(value) => Cell::from(value.as_str()),
|
||||||
None => Cell::from("NULL").style(
|
None => Cell::from("NULL").style(
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::DarkGray)
|
.fg(Color::DarkGray)
|
||||||
.add_modifier(Modifier::ITALIC),
|
.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<Item = &'a str>,
|
||||||
|
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<'_> {
|
fn panel(title: &str, focused: bool) -> Block<'_> {
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.title(title)
|
.title(title)
|
||||||
@@ -543,6 +755,90 @@ fn moved_index(selected: Option<usize>, count: usize, delta: isize) -> Option<us
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::moved_index;
|
use super::moved_index;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rectangular_selection_copies_full_cells_and_resets_on_data_changes() -> 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]
|
#[test]
|
||||||
fn navigation_clamps_to_available_items() {
|
fn navigation_clamps_to_available_items() {
|
||||||
assert_eq!(moved_index(None, 0, 1), None);
|
assert_eq!(moved_index(None, 0, 1), None);
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match key.code {
|
match key.code {
|
||||||
|
_ if focus == Focus::Rows
|
||||||
|
&& browser.as_mut().is_some_and(|app| app.handle_rows_command(key)) => {}
|
||||||
KeyCode::Char('?')
|
KeyCode::Char('?')
|
||||||
if focus != Focus::Query
|
if focus != Focus::Query
|
||||||
|| !browser.as_ref().is_some_and(App::query_insert_mode) =>
|
|| !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\
|
s Toggle rows / column schema\n\
|
||||||
n/p, PgDn/PgUp Next / previous 100-row relation page\n\
|
n/p, PgDn/PgUp Next / previous 100-row relation page\n\
|
||||||
r Refresh relations and active relation\n\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\
|
Query editor\n\
|
||||||
i/a/I/A Enter Insert mode (at/after/start/end)\n\
|
i/a/I/A Enter Insert mode (at/after/start/end)\n\
|
||||||
hjkl / arrows Move cursor in Normal mode\n\
|
hjkl / arrows Move cursor in Normal mode\n\
|
||||||
|
|||||||
+10
@@ -72,6 +72,16 @@ Navigation:
|
|||||||
s Toggle rows and column schema
|
s Toggle rows and column schema
|
||||||
Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too)
|
Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too)
|
||||||
n/p, PgDn/Up Next / previous 100-row relation page
|
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
|
c Add a configured database in a floating window
|
||||||
i Show configured database info (Esc/Enter closes; passwords omitted)
|
i Show configured database info (Esc/Enter closes; passwords omitted)
|
||||||
Works on a saved database entry or any of its table/view children.
|
Works on a saved database entry or any of its table/view children.
|
||||||
|
|||||||
Reference in New Issue
Block a user