diff --git a/assets/keybindings.toml b/assets/keybindings.toml index 9013c47..bd59cb1 100644 --- a/assets/keybindings.toml +++ b/assets/keybindings.toml @@ -72,6 +72,7 @@ download_as = ["shift-s"] encoding = ["e"] toggle_wrap = ["w"] toggle_number = ["n"] +copy = ["c"] [help] close = ["?", "backspace"] diff --git a/docs/src/features/object-preview.md b/docs/src/features/object-preview.md index a85214f..c650520 100644 --- a/docs/src/features/object-preview.md +++ b/docs/src/features/object-preview.md @@ -10,6 +10,7 @@ - It must be enabled in the [config](../configurations/config-file-format.md#previewauto_detect_encoding) - Download object - Download a single selected object +- Copy the text content to clipboard ![Object Preview](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview.png) ![Object Preview Image](https://raw.githubusercontent.com/lusingander/stu/refs/heads/master/img/object-preview-image.png) diff --git a/src/keys.rs b/src/keys.rs index 5cc22c5..5683639 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -68,6 +68,7 @@ pub enum UserEvent { ObjectPreviewEncoding, ObjectPreviewToggleWrap, ObjectPreviewToggleNumber, + ObjectPreviewCopy, HelpClose, InputDialogClose, InputDialogApply, @@ -183,6 +184,7 @@ fn build_user_event_mapper( set_event_to_map(&mut map, &bindings, "object_preview", "encoding", UserEvent::ObjectPreviewEncoding)?; set_event_to_map(&mut map, &bindings, "object_preview", "toggle_wrap", UserEvent::ObjectPreviewToggleWrap)?; set_event_to_map(&mut map, &bindings, "object_preview", "toggle_number", UserEvent::ObjectPreviewToggleNumber)?; + set_event_to_map(&mut map, &bindings, "object_preview", "copy", UserEvent::ObjectPreviewCopy)?; set_event_to_map(&mut map, &bindings, "help", "close", UserEvent::HelpClose)?; diff --git a/src/pages/object_preview.rs b/src/pages/object_preview.rs index edcfa2c..df5a0a0 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -141,6 +141,9 @@ impl ObjectPreviewPage { UserEvent::ObjectPreviewEncoding => { self.open_encoding_dialog(); } + UserEvent::ObjectPreviewCopy => { + self.copy_text_content(); + } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); } @@ -159,6 +162,9 @@ impl ObjectPreviewPage { self.open_save_dialog(); self.disable_image_render(); } + UserEvent::ObjectPreviewCopy => { + self.tx.send(AppEventType::NotifyWarn("Cannot copy image content. Copy is only available for text files.".to_string())); + } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); } @@ -264,6 +270,7 @@ impl ObjectPreviewPage { BuildHelpsItem::new(UserEvent::ObjectPreviewDownload, "Download object"), BuildHelpsItem::new(UserEvent::ObjectPreviewDownloadAs, "Download object as"), BuildHelpsItem::new(UserEvent::ObjectPreviewEncoding, "Open encoding dialog"), + BuildHelpsItem::new(UserEvent::ObjectPreviewCopy, "Copy content to clipboard"), ] }, (ViewState::Default, PreviewType::Image(_)) => { @@ -304,6 +311,7 @@ impl ObjectPreviewPage { BuildShortHelpsItem::group(vec![UserEvent::ObjectPreviewGoToTop, UserEvent::ObjectPreviewGoToBottom], "Top/End", 5), BuildShortHelpsItem::group(vec![UserEvent::ObjectPreviewDownload, UserEvent::ObjectPreviewDownloadAs], "Download", 3), BuildShortHelpsItem::single(UserEvent::ObjectPreviewEncoding, "Encoding", 4), + BuildShortHelpsItem::single(UserEvent::ObjectPreviewCopy, "Copy", 6), BuildShortHelpsItem::single(UserEvent::ObjectPreviewBack, "Close", 1), BuildShortHelpsItem::single(UserEvent::Help, "Help", 0), ] @@ -342,6 +350,19 @@ impl ObjectPreviewPage { self.view_state = ViewState::SaveDialog(InputDialogState::new(name)); } + fn copy_text_content(&mut self) { + if let PreviewType::Text(state) = &self.preview_type { + let encoding: &encoding_rs::Encoding = state.encoding.into(); + let (content, _, _) = encoding.decode(&self.object.bytes); + let content_string = content.into_owned(); + + self.tx.send(AppEventType::CopyToClipboard( + self.file_detail.name.clone(), + content_string, + )); + } + } + fn close_save_dialog(&mut self) { self.view_state = ViewState::Default; } @@ -426,7 +447,13 @@ mod tests { use super::*; use chrono::{DateTime, Local, NaiveDateTime}; - use ratatui::{backend::TestBackend, buffer::Buffer, style::Color, Terminal}; + use ratatui::{ + backend::TestBackend, + buffer::Buffer, + crossterm::event::{KeyCode, KeyModifiers}, + style::Color, + Terminal, + }; fn object(ss: &[&str]) -> RawObject { RawObject { @@ -589,4 +616,96 @@ mod tests { object_url: "https://bucket-1.s3.ap-northeast-1.amazonaws.com/file.txt".to_string(), } } + + #[tokio::test] + async fn test_copy_text_content() { + let ctx = Rc::default(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let tx = Sender::new(tx); + + let file_detail = file_detail(); + let preview = ["Hello, world!", "This is test content."]; + let object = object(&preview); + let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); + + page.handle_key( + vec![UserEvent::ObjectPreviewCopy], + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), + ); + + assert!(matches!(page.preview_type, PreviewType::Text(_))); + } + + #[tokio::test] + async fn test_copy_image_content_shows_warning() { + use crate::event::AppEventType; + + let ctx = Rc::default(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let tx = Sender::new(tx); + + let image_bytes = vec![ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR chunk size + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width: 1 + 0x00, 0x00, 0x00, 0x01, // height: 1 + 0x08, 0x02, // bit depth: 8, color type: 2 (RGB) + 0x00, 0x00, 0x00, // compression, filter, interlace + ]; + let object = RawObject { bytes: image_bytes }; + + let mut file_detail = file_detail(); + file_detail.name = "image.png".to_string(); + file_detail.content_type = "image/png".to_string(); + + let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); + + assert!(matches!(page.preview_type, PreviewType::Image(_))); + + // NOTE: Clear any initial warning messages (like "Image preview is disabled") + while rx.try_recv().is_ok() { + // Drain events + } + + page.handle_key( + vec![UserEvent::ObjectPreviewCopy], + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), + ); + + if let Ok(event) = rx.try_recv() { + match event { + AppEventType::NotifyWarn(msg) => { + assert!( + msg.contains("Cannot copy image content"), + "Message was: {}", + msg + ); + } + _ => panic!("Expected NotifyWarn event, got: {:?}", event), + } + } else { + panic!("Expected NotifyWarn event to be sent"); + } + } + + #[test] + fn test_copy_respects_encoding() { + let ctx = Rc::default(); + let tx = sender(); + + let text = "Hello, 世界!"; + let utf16_bytes: Vec = text.encode_utf16().flat_map(|c| c.to_be_bytes()).collect(); + + let object = RawObject { bytes: utf16_bytes }; + + let file_detail = file_detail(); + let page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); + + if let PreviewType::Text(ref state) = page.preview_type { + assert!(matches!(state.encoding, _)); + } else { + panic!("Expected text preview type"); + } + } }