From 8849a86871fd4c8875a17014bb0d58cc592ec1e6 Mon Sep 17 00:00:00 2001 From: Paul Lavender-Jones <8403295+paullj@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:22:24 +0000 Subject: [PATCH 1/3] feat: add yank in text preview --- README.md | 1 + assets/keybindings.toml | 1 + docs/src/features/object-preview.md | 2 ++ src/keys.rs | 2 ++ src/pages/object_preview.rs | 23 +++++++++++++++++++++++ 5 files changed, 29 insertions(+) diff --git a/README.md b/README.md index bff7af8..ea8ce56 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ STU provides the following features: - Recursive object downloads - Previews with syntax highlighting for text files and inline rendering for images +- Yank text content to clipboard from preview - Access to previous object versions - Customizable key bindings - Support for S3-compatible storage diff --git a/assets/keybindings.toml b/assets/keybindings.toml index 9013c47..b911b25 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"] +yank = ["y"] [help] close = ["?", "backspace"] diff --git a/docs/src/features/object-preview.md b/docs/src/features/object-preview.md index a85214f..f07449b 100644 --- a/docs/src/features/object-preview.md +++ b/docs/src/features/object-preview.md @@ -10,6 +10,8 @@ - It must be enabled in the [config](../configurations/config-file-format.md#previewauto_detect_encoding) - Download object - Download a single selected object +- Yank content to clipboard + - Press y to 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..1511b29 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -68,6 +68,7 @@ pub enum UserEvent { ObjectPreviewEncoding, ObjectPreviewToggleWrap, ObjectPreviewToggleNumber, + ObjectPreviewYank, 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", "yank", UserEvent::ObjectPreviewYank)?; 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..7b303ed 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -6,6 +6,7 @@ use crate::{ app::AppContext, environment::ImagePicker, event::{AppEventType, Sender}, + file::copy_to_clipboard, handle_user_events, handle_user_events_with_default, help::{ build_help_spans, build_short_help_spans, BuildHelpsItem, BuildShortHelpsItem, Spans, @@ -141,6 +142,9 @@ impl ObjectPreviewPage { UserEvent::ObjectPreviewEncoding => { self.open_encoding_dialog(); } + UserEvent::ObjectPreviewYank => { + self.yank_text_content(); + } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); } @@ -264,6 +268,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::ObjectPreviewYank, "Yank content to clipboard"), ] }, (ViewState::Default, PreviewType::Image(_)) => { @@ -304,6 +309,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::ObjectPreviewYank, "Yank", 4), BuildShortHelpsItem::single(UserEvent::ObjectPreviewBack, "Close", 1), BuildShortHelpsItem::single(UserEvent::Help, "Help", 0), ] @@ -342,6 +348,23 @@ impl ObjectPreviewPage { self.view_state = ViewState::SaveDialog(InputDialogState::new(name)); } + fn yank_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(); + + match copy_to_clipboard(content_string) { + Ok(_) => { + self.tx.send(AppEventType::NotifyInfo("Content yanked to clipboard".to_string())); + } + Err(e) => { + self.tx.send(AppEventType::NotifyError(e)); + } + } + } + } + fn close_save_dialog(&mut self) { self.view_state = ViewState::Default; } From c3e7a3f2c0b76a4e042905d0ab9ca50b57475939 Mon Sep 17 00:00:00 2001 From: Paul Lavender-Jones <8403295+paullj@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:55:47 +0000 Subject: [PATCH 2/3] chore: add tests for object_preview --- src/pages/object_preview.rs | 107 +++++++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 2 deletions(-) diff --git a/src/pages/object_preview.rs b/src/pages/object_preview.rs index 7b303ed..3f7b41c 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -163,6 +163,9 @@ impl ObjectPreviewPage { self.open_save_dialog(); self.disable_image_render(); } + UserEvent::ObjectPreviewYank => { + self.tx.send(AppEventType::NotifyWarn("Cannot yank image content. Yank is only available for text files.".to_string())); + } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); } @@ -356,7 +359,9 @@ impl ObjectPreviewPage { match copy_to_clipboard(content_string) { Ok(_) => { - self.tx.send(AppEventType::NotifyInfo("Content yanked to clipboard".to_string())); + self.tx.send(AppEventType::NotifyInfo( + "Content yanked to clipboard".to_string(), + )); } Err(e) => { self.tx.send(AppEventType::NotifyError(e)); @@ -449,7 +454,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 { @@ -612,4 +623,96 @@ mod tests { object_url: "https://bucket-1.s3.ap-northeast-1.amazonaws.com/file.txt".to_string(), } } + + #[tokio::test] + async fn test_yank_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::ObjectPreviewYank], + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), + ); + + assert!(matches!(page.preview_type, PreviewType::Text(_))); + } + + #[tokio::test] + async fn test_yank_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::ObjectPreviewYank], + KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), + ); + + if let Ok(event) = rx.try_recv() { + match event { + AppEventType::NotifyWarn(msg) => { + assert!( + msg.contains("Cannot yank image content"), + "Message was: {}", + msg + ); + } + _ => panic!("Expected NotifyWarn event, got: {:?}", event), + } + } else { + panic!("Expected NotifyWarn event to be sent"); + } + } + + #[test] + fn test_yank_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"); + } + } } From 5bc41a01a34f9b59ff00c503dc8b886f9029ee7a Mon Sep 17 00:00:00 2001 From: Paul Lavender-Jones <8403295+paullj@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:34:35 +0000 Subject: [PATCH 3/3] fix: change to copy and use events --- README.md | 1 - assets/keybindings.toml | 2 +- docs/src/features/object-preview.md | 3 +-- src/keys.rs | 4 +-- src/pages/object_preview.rs | 41 ++++++++++++----------------- 5 files changed, 21 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index ea8ce56..bff7af8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ STU provides the following features: - Recursive object downloads - Previews with syntax highlighting for text files and inline rendering for images -- Yank text content to clipboard from preview - Access to previous object versions - Customizable key bindings - Support for S3-compatible storage diff --git a/assets/keybindings.toml b/assets/keybindings.toml index b911b25..bd59cb1 100644 --- a/assets/keybindings.toml +++ b/assets/keybindings.toml @@ -72,7 +72,7 @@ download_as = ["shift-s"] encoding = ["e"] toggle_wrap = ["w"] toggle_number = ["n"] -yank = ["y"] +copy = ["c"] [help] close = ["?", "backspace"] diff --git a/docs/src/features/object-preview.md b/docs/src/features/object-preview.md index f07449b..c650520 100644 --- a/docs/src/features/object-preview.md +++ b/docs/src/features/object-preview.md @@ -10,8 +10,7 @@ - It must be enabled in the [config](../configurations/config-file-format.md#previewauto_detect_encoding) - Download object - Download a single selected object -- Yank content to clipboard - - Press y to copy the text content to clipboard +- 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 1511b29..5683639 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -68,7 +68,7 @@ pub enum UserEvent { ObjectPreviewEncoding, ObjectPreviewToggleWrap, ObjectPreviewToggleNumber, - ObjectPreviewYank, + ObjectPreviewCopy, HelpClose, InputDialogClose, InputDialogApply, @@ -184,7 +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", "yank", UserEvent::ObjectPreviewYank)?; + 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 3f7b41c..df5a0a0 100644 --- a/src/pages/object_preview.rs +++ b/src/pages/object_preview.rs @@ -6,7 +6,6 @@ use crate::{ app::AppContext, environment::ImagePicker, event::{AppEventType, Sender}, - file::copy_to_clipboard, handle_user_events, handle_user_events_with_default, help::{ build_help_spans, build_short_help_spans, BuildHelpsItem, BuildShortHelpsItem, Spans, @@ -142,8 +141,8 @@ impl ObjectPreviewPage { UserEvent::ObjectPreviewEncoding => { self.open_encoding_dialog(); } - UserEvent::ObjectPreviewYank => { - self.yank_text_content(); + UserEvent::ObjectPreviewCopy => { + self.copy_text_content(); } UserEvent::Help => { self.tx.send(AppEventType::OpenHelp); @@ -163,8 +162,8 @@ impl ObjectPreviewPage { self.open_save_dialog(); self.disable_image_render(); } - UserEvent::ObjectPreviewYank => { - self.tx.send(AppEventType::NotifyWarn("Cannot yank image content. Yank is only available for text files.".to_string())); + 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); @@ -271,7 +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::ObjectPreviewYank, "Yank content to clipboard"), + BuildHelpsItem::new(UserEvent::ObjectPreviewCopy, "Copy content to clipboard"), ] }, (ViewState::Default, PreviewType::Image(_)) => { @@ -312,7 +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::ObjectPreviewYank, "Yank", 4), + BuildShortHelpsItem::single(UserEvent::ObjectPreviewCopy, "Copy", 6), BuildShortHelpsItem::single(UserEvent::ObjectPreviewBack, "Close", 1), BuildShortHelpsItem::single(UserEvent::Help, "Help", 0), ] @@ -351,22 +350,16 @@ impl ObjectPreviewPage { self.view_state = ViewState::SaveDialog(InputDialogState::new(name)); } - fn yank_text_content(&mut self) { + 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(); - match copy_to_clipboard(content_string) { - Ok(_) => { - self.tx.send(AppEventType::NotifyInfo( - "Content yanked to clipboard".to_string(), - )); - } - Err(e) => { - self.tx.send(AppEventType::NotifyError(e)); - } - } + self.tx.send(AppEventType::CopyToClipboard( + self.file_detail.name.clone(), + content_string, + )); } } @@ -625,7 +618,7 @@ mod tests { } #[tokio::test] - async fn test_yank_text_content() { + async fn test_copy_text_content() { let ctx = Rc::default(); let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); let tx = Sender::new(tx); @@ -636,7 +629,7 @@ mod tests { let mut page = ObjectPreviewPage::new(file_detail, None, object, ctx, tx); page.handle_key( - vec![UserEvent::ObjectPreviewYank], + vec![UserEvent::ObjectPreviewCopy], KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), ); @@ -644,7 +637,7 @@ mod tests { } #[tokio::test] - async fn test_yank_image_content_shows_warning() { + async fn test_copy_image_content_shows_warning() { use crate::event::AppEventType; let ctx = Rc::default(); @@ -676,7 +669,7 @@ mod tests { } page.handle_key( - vec![UserEvent::ObjectPreviewYank], + vec![UserEvent::ObjectPreviewCopy], KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()), ); @@ -684,7 +677,7 @@ mod tests { match event { AppEventType::NotifyWarn(msg) => { assert!( - msg.contains("Cannot yank image content"), + msg.contains("Cannot copy image content"), "Message was: {}", msg ); @@ -697,7 +690,7 @@ mod tests { } #[test] - fn test_yank_respects_encoding() { + fn test_copy_respects_encoding() { let ctx = Rc::default(); let tx = sender();