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


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


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();