@@ -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