Skip to content

Commit f4cc0af

Browse files
authored
Merge pull request #91 from paullj/feat/add-yanking-in-preview
Add copying text in object preview
2 parents 9ec40e8 + 5bc41a0 commit f4cc0af

4 files changed

Lines changed: 124 additions & 1 deletion

File tree

assets/keybindings.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ download_as = ["shift-s"]
7272
encoding = ["e"]
7373
toggle_wrap = ["w"]
7474
toggle_number = ["n"]
75+
copy = ["c"]
7576

7677
[help]
7778
close = ["?", "backspace"]

docs/src/features/object-preview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- It must be enabled in the [config](../configurations/config-file-format.md#previewauto_detect_encoding)
1111
- Download object
1212
- Download a single selected object
13+
- Copy the text content to clipboard
1314

1415
![Object Preview](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview.png)
1516
![Object Preview Image](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview-image.png)

src/keys.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ pub enum UserEvent {
6868
ObjectPreviewEncoding,
6969
ObjectPreviewToggleWrap,
7070
ObjectPreviewToggleNumber,
71+
ObjectPreviewCopy,
7172
HelpClose,
7273
InputDialogClose,
7374
InputDialogApply,
@@ -183,6 +184,7 @@ fn build_user_event_mapper(
183184
set_event_to_map(&mut map, &bindings, "object_preview", "encoding", UserEvent::ObjectPreviewEncoding)?;
184185
set_event_to_map(&mut map, &bindings, "object_preview", "toggle_wrap", UserEvent::ObjectPreviewToggleWrap)?;
185186
set_event_to_map(&mut map, &bindings, "object_preview", "toggle_number", UserEvent::ObjectPreviewToggleNumber)?;
187+
set_event_to_map(&mut map, &bindings, "object_preview", "copy", UserEvent::ObjectPreviewCopy)?;
186188

187189
set_event_to_map(&mut map, &bindings, "help", "close", UserEvent::HelpClose)?;
188190

src/pages/object_preview.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ impl ObjectPreviewPage {
141141
UserEvent::ObjectPreviewEncoding => {
142142
self.open_encoding_dialog();
143143
}
144+
UserEvent::ObjectPreviewCopy => {
145+
self.copy_text_content();
146+
}
144147
UserEvent::Help => {
145148
self.tx.send(AppEventType::OpenHelp);
146149
}
@@ -159,6 +162,9 @@ impl ObjectPreviewPage {
159162
self.open_save_dialog();
160163
self.disable_image_render();
161164
}
165+
UserEvent::ObjectPreviewCopy => {
166+
self.tx.send(AppEventType::NotifyWarn("Cannot copy image content. Copy is only available for text files.".to_string()));
167+
}
162168
UserEvent::Help => {
163169
self.tx.send(AppEventType::OpenHelp);
164170
}
@@ -264,6 +270,7 @@ impl ObjectPreviewPage {
264270
BuildHelpsItem::new(UserEvent::ObjectPreviewDownload, "Download object"),
265271
BuildHelpsItem::new(UserEvent::ObjectPreviewDownloadAs, "Download object as"),
266272
BuildHelpsItem::new(UserEvent::ObjectPreviewEncoding, "Open encoding dialog"),
273+
BuildHelpsItem::new(UserEvent::ObjectPreviewCopy, "Copy content to clipboard"),
267274
]
268275
},
269276
(ViewState::Default, PreviewType::Image(_)) => {
@@ -304,6 +311,7 @@ impl ObjectPreviewPage {
304311
BuildShortHelpsItem::group(vec![UserEvent::ObjectPreviewGoToTop, UserEvent::ObjectPreviewGoToBottom], "Top/End", 5),
305312
BuildShortHelpsItem::group(vec![UserEvent::ObjectPreviewDownload, UserEvent::ObjectPreviewDownloadAs], "Download", 3),
306313
BuildShortHelpsItem::single(UserEvent::ObjectPreviewEncoding, "Encoding", 4),
314+
BuildShortHelpsItem::single(UserEvent::ObjectPreviewCopy, "Copy", 6),
307315
BuildShortHelpsItem::single(UserEvent::ObjectPreviewBack, "Close", 1),
308316
BuildShortHelpsItem::single(UserEvent::Help, "Help", 0),
309317
]
@@ -342,6 +350,19 @@ impl ObjectPreviewPage {
342350
self.view_state = ViewState::SaveDialog(InputDialogState::new(name));
343351
}
344352

353+
fn copy_text_content(&mut self) {
354+
if let PreviewType::Text(state) = &self.preview_type {
355+
let encoding: &encoding_rs::Encoding = state.encoding.into();
356+
let (content, _, _) = encoding.decode(&self.object.bytes);
357+
let content_string = content.into_owned();
358+
359+
self.tx.send(AppEventType::CopyToClipboard(
360+
self.file_detail.name.clone(),
361+
content_string,
362+
));
363+
}
364+
}
365+
345366
fn close_save_dialog(&mut self) {
346367
self.view_state = ViewState::Default;
347368
}
@@ -426,7 +447,13 @@ mod tests {
426447

427448
use super::*;
428449
use chrono::{DateTime, Local, NaiveDateTime};
429-
use ratatui::{backend::TestBackend, buffer::Buffer, style::Color, Terminal};
450+
use ratatui::{
451+
backend::TestBackend,
452+
buffer::Buffer,
453+
crossterm::event::{KeyCode, KeyModifiers},
454+
style::Color,
455+
Terminal,
456+
};
430457

431458
fn object(ss: &[&str]) -> RawObject {
432459
RawObject {
@@ -589,4 +616,96 @@ mod tests {
589616
object_url: "https://bucket-1.s3.ap-northeast-1.amazonaws.com/file.txt".to_string(),
590617
}
591618
}
619+
620+
#[tokio::test]
621+
async fn test_copy_text_content() {
622+
let ctx = Rc::default();
623+
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
624+
let tx = Sender::new(tx);
625+
626+
let file_detail = file_detail();
627+
let preview = ["Hello, world!", "This is test content."];
628+
let object = object(&preview);
629+
let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx);
630+
631+
page.handle_key(
632+
vec![UserEvent::ObjectPreviewCopy],
633+
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()),
634+
);
635+
636+
assert!(matches!(page.preview_type, PreviewType::Text(_)));
637+
}
638+
639+
#[tokio::test]
640+
async fn test_copy_image_content_shows_warning() {
641+
use crate::event::AppEventType;
642+
643+
let ctx = Rc::default();
644+
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
645+
let tx = Sender::new(tx);
646+
647+
let image_bytes = vec![
648+
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
649+
0x00, 0x00, 0x00, 0x0D, // IHDR chunk size
650+
0x49, 0x48, 0x44, 0x52, // "IHDR"
651+
0x00, 0x00, 0x00, 0x01, // width: 1
652+
0x00, 0x00, 0x00, 0x01, // height: 1
653+
0x08, 0x02, // bit depth: 8, color type: 2 (RGB)
654+
0x00, 0x00, 0x00, // compression, filter, interlace
655+
];
656+
let object = RawObject { bytes: image_bytes };
657+
658+
let mut file_detail = file_detail();
659+
file_detail.name = "image.png".to_string();
660+
file_detail.content_type = "image/png".to_string();
661+
662+
let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx);
663+
664+
assert!(matches!(page.preview_type, PreviewType::Image(_)));
665+
666+
// NOTE: Clear any initial warning messages (like "Image preview is disabled")
667+
while rx.try_recv().is_ok() {
668+
// Drain events
669+
}
670+
671+
page.handle_key(
672+
vec![UserEvent::ObjectPreviewCopy],
673+
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()),
674+
);
675+
676+
if let Ok(event) = rx.try_recv() {
677+
match event {
678+
AppEventType::NotifyWarn(msg) => {
679+
assert!(
680+
msg.contains("Cannot copy image content"),
681+
"Message was: {}",
682+
msg
683+
);
684+
}
685+
_ => panic!("Expected NotifyWarn event, got: {:?}", event),
686+
}
687+
} else {
688+
panic!("Expected NotifyWarn event to be sent");
689+
}
690+
}
691+
692+
#[test]
693+
fn test_copy_respects_encoding() {
694+
let ctx = Rc::default();
695+
let tx = sender();
696+
697+
let text = "Hello, 世界!";
698+
let utf16_bytes: Vec<u8> = text.encode_utf16().flat_map(|c| c.to_be_bytes()).collect();
699+
700+
let object = RawObject { bytes: utf16_bytes };
701+
702+
let file_detail = file_detail();
703+
let page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx);
704+
705+
if let PreviewType::Text(ref state) = page.preview_type {
706+
assert!(matches!(state.encoding, _));
707+
} else {
708+
panic!("Expected text preview type");
709+
}
710+
}
592711
}

0 commit comments

Comments
 (0)