use crate::app::{App, Focus};
use crate::config::ClipboardBackend;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::backend::copy_to_clipboard;
pub fn handle_clipboard_key(app: &mut App, key: KeyEvent, backend: ClipboardBackend) -> bool {
if key.code == KeyCode::Char('y') && key.modifiers.contains(KeyModifiers::CONTROL) {
return copy_focused_content(app, backend);
}
false
}
pub fn handle_yank_key(app: &mut App, backend: ClipboardBackend) -> bool {
copy_focused_content(app, backend)
}
fn copy_focused_content(app: &mut App, backend: ClipboardBackend) -> bool {
match app.focus {
Focus::InputField => copy_query(app, backend),
Focus::ResultsPane => copy_result(app, backend),
}
}
fn copy_query(app: &mut App, backend: ClipboardBackend) -> bool {
let query = app.query();
if query.is_empty() {
return false;
}
if copy_to_clipboard(query, backend).is_ok() {
app.notification.show("Copied query!");
true
} else {
false
}
}
fn copy_result(app: &mut App, backend: ClipboardBackend) -> bool {
let result = match &app.query.result {
Ok(text) => strip_ansi_codes(text),
Err(_) => return false, };
if result.is_empty() {
return false;
}
if copy_to_clipboard(&result, backend).is_ok() {
app.notification.show("Copied result!");
true
} else {
false
}
}
pub fn strip_ansi_codes(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.peek() {
Some(&'[') => {
chars.next(); while let Some(&next) = chars.peek() {
chars.next();
if next.is_ascii_alphabetic() {
break;
}
}
}
Some(&']') => {
chars.next(); while let Some(&next) = chars.peek() {
if next == '\x07' {
chars.next();
break;
}
if next == '\x1b' {
chars.next();
if chars.peek() == Some(&'\\') {
chars.next();
}
break;
}
chars.next();
}
}
Some(_) => {
chars.next();
}
None => {
}
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::test_helpers::test_app;
use proptest::prelude::*;
#[test]
fn test_strip_ansi_codes_no_codes() {
assert_eq!(strip_ansi_codes("hello world"), "hello world");
}
#[test]
fn test_strip_ansi_codes_simple_color() {
assert_eq!(strip_ansi_codes("\x1b[31mhello\x1b[0m"), "hello");
}
#[test]
fn test_strip_ansi_codes_multiple_colors() {
assert_eq!(
strip_ansi_codes("\x1b[1;31mbold red\x1b[0m normal"),
"bold red normal"
);
}
#[test]
fn test_strip_ansi_codes_empty_string() {
assert_eq!(strip_ansi_codes(""), "");
}
#[test]
fn test_strip_ansi_codes_only_escape_sequences() {
assert_eq!(strip_ansi_codes("\x1b[31m\x1b[0m"), "");
}
#[test]
fn test_strip_ansi_codes_preserves_newlines() {
assert_eq!(
strip_ansi_codes("\x1b[32mline1\x1b[0m\nline2"),
"line1\nline2"
);
}
#[test]
fn test_strip_ansi_codes_osc_sequence() {
assert_eq!(strip_ansi_codes("\x1b]0;title\x07text"), "text");
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_ansi_stripping_preserves_non_ansi_content(
text in "[^\x1b]*"
) {
let result = strip_ansi_codes(&text);
prop_assert_eq!(
result, text,
"Text without ANSI codes should be unchanged"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_ansi_stripping_removes_all_escape_sequences(
prefix in "[a-zA-Z0-9 ]{0,20}",
suffix in "[a-zA-Z0-9 ]{0,20}",
ansi_params in "[0-9;]{0,10}",
ansi_letter in "[A-Za-z]",
) {
let text_with_ansi = format!(
"{}\x1b[{}{}{}",
prefix,
ansi_params,
ansi_letter,
suffix
);
let result = strip_ansi_codes(&text_with_ansi);
prop_assert!(
!result.contains('\x1b'),
"Stripped text should not contain escape character. Input: {:?}, Output: {:?}",
text_with_ansi,
result
);
prop_assert_eq!(
result,
format!("{}{}", prefix, suffix),
"Stripped text should be prefix + suffix"
);
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_empty_content_rejection_query(
_whitespace in "[ \t\n]*"
) {
let mut app = test_app("{}");
let result = copy_query(&mut app, ClipboardBackend::Osc52);
prop_assert!(
!result,
"Empty query should be rejected, but copy returned true"
);
prop_assert!(
app.notification.current().is_none(),
"No notification should be shown for rejected empty copy"
);
}
#[test]
fn prop_empty_content_rejection_result(
ansi_params in "[0-9;]{0,10}",
ansi_letter in "[A-Za-z]",
) {
let mut app = test_app("{}");
let ansi_only = format!("\x1b[{}{}", ansi_params, ansi_letter);
app.query.result = Ok(ansi_only);
let result = copy_result(&mut app, ClipboardBackend::Osc52);
prop_assert!(
!result,
"Result that is empty after ANSI stripping should be rejected"
);
prop_assert!(
app.notification.current().is_none(),
"No notification should be shown for rejected empty copy"
);
}
}
#[test]
fn test_copy_query_rejects_empty() {
let mut app = test_app("{}");
let result = copy_query(&mut app, ClipboardBackend::Osc52);
assert!(!result, "Empty query should be rejected");
assert!(
app.notification.current().is_none(),
"No notification for rejected copy"
);
}
#[test]
fn test_copy_result_rejects_empty() {
let mut app = test_app("{}");
app.query.result = Ok(String::new());
let result = copy_result(&mut app, ClipboardBackend::Osc52);
assert!(!result, "Empty result should be rejected");
assert!(
app.notification.current().is_none(),
"No notification for rejected copy"
);
}
#[test]
fn test_copy_result_rejects_ansi_only() {
let mut app = test_app("{}");
app.query.result = Ok("\x1b[31m\x1b[0m".to_string());
let result = copy_result(&mut app, ClipboardBackend::Osc52);
assert!(!result, "ANSI-only result should be rejected");
assert!(
app.notification.current().is_none(),
"No notification for rejected copy"
);
}
#[test]
fn test_copy_result_rejects_error() {
let mut app = test_app("{}");
app.query.result = Err("some error".to_string());
let result = copy_result(&mut app, ClipboardBackend::Osc52);
assert!(!result, "Error result should be rejected");
assert!(
app.notification.current().is_none(),
"No notification for rejected copy"
);
}
#[test]
fn test_copy_query_accepts_non_empty() {
let mut app = test_app("{}");
app.input.textarea.insert_str(".foo");
let result = copy_query(&mut app, ClipboardBackend::Osc52);
assert!(result, "Non-empty query should be accepted");
assert_eq!(
app.notification.current_message(),
Some("Copied query!"),
"Notification should be shown for successful copy"
);
}
#[test]
fn test_copy_result_accepts_non_empty() {
let mut app = test_app("{}");
app.query.result = Ok(r#"{"key": "value"}"#.to_string());
let result = copy_result(&mut app, ClipboardBackend::Osc52);
assert!(result, "Non-empty result should be accepted");
assert_eq!(
app.notification.current_message(),
Some("Copied result!"),
"Notification should be shown for successful copy"
);
}
}