Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions assets/keybindings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ download_as = ["shift-s"]
encoding = ["e"]
toggle_wrap = ["w"]
toggle_number = ["n"]
yank = ["y"]

[help]
close = ["?", "backspace"]
Expand Down
2 changes: 2 additions & 0 deletions docs/src/features/object-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>y</kbd> 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)
Expand Down
2 changes: 2 additions & 0 deletions src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ pub enum UserEvent {
ObjectPreviewEncoding,
ObjectPreviewToggleWrap,
ObjectPreviewToggleNumber,
ObjectPreviewYank,
HelpClose,
InputDialogClose,
InputDialogApply,
Expand Down Expand Up @@ -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)?;

Expand Down
128 changes: 127 additions & 1 deletion src/pages/object_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand All @@ -159,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);
}
Expand Down Expand Up @@ -264,6 +271,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(_)) => {
Expand Down Expand Up @@ -304,6 +312,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),
]
Expand Down Expand Up @@ -342,6 +351,25 @@ 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;
}
Expand Down Expand Up @@ -426,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 {
Expand Down Expand Up @@ -589,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<u8> = 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");
}
}
}