1use ratatui::{buffer::Buffer, text::Span};
3use std::{
4 cell::RefCell,
5 time::{Duration, Instant},
6};
7
8const NOTICE_DURATION: Duration = Duration::from_secs(4);
9
10enum CopyRequest {
11 Text(String),
12 Snapshot,
13}
14
15pub struct CopyNotice {
16 pub message: String,
17 pub failed: bool,
18 created: Instant,
19}
20
21#[derive(Default)]
22pub struct ClipboardState {
23 pending: RefCell<Option<CopyRequest>>,
24 notice: Option<CopyNotice>,
25}
26
27impl ClipboardState {
28 pub fn request_text(&self, text: impl Into<String>) {
31 self.pending.replace(Some(CopyRequest::Text(text.into())));
32 }
33 pub fn request_snapshot(&self) {
34 self.pending.replace(Some(CopyRequest::Snapshot));
35 }
36 pub fn snapshot_requested(&self) -> bool {
37 matches!(*self.pending.borrow(), Some(CopyRequest::Snapshot))
38 }
39 pub fn notice(&self) -> Option<&CopyNotice> {
40 self.notice
41 .as_ref()
42 .filter(|notice| notice.created.elapsed() < NOTICE_DURATION)
43 }
44 pub fn expire_notice(&mut self) -> bool {
45 if self
46 .notice
47 .as_ref()
48 .is_some_and(|notice| notice.created.elapsed() >= NOTICE_DURATION)
49 {
50 self.notice = None;
51 true
52 } else {
53 false
54 }
55 }
56 pub fn complete_with(
59 &mut self,
60 buffer: &Buffer,
61 write: impl FnOnce(String) -> Result<(), String>,
62 ) -> bool {
63 let Some(request) = self.pending.take() else {
64 return false;
65 };
66 let (text, success) = match request {
67 CopyRequest::Text(text) => (text, "Copied to clipboard"),
68 CopyRequest::Snapshot => (snapshot_text(buffer), "Snapshot copied to clipboard"),
69 };
70 let (message, failed) = match write(text) {
71 Ok(()) => (success.into(), false),
72 Err(error) => (format!("Failed to copy: {error}"), true),
73 };
74 self.notice = Some(CopyNotice {
75 message,
76 failed,
77 created: Instant::now(),
78 });
79 true
80 }
81}
82
83#[derive(Default)]
86pub struct ClipboardService {
87 backend: Option<arboard::Clipboard>,
88}
89impl ClipboardService {
90 pub fn flush(&mut self, state: &mut ClipboardState, buffer: &Buffer) -> bool {
91 state.complete_with(buffer, |text| {
92 if self.backend.is_none() {
93 self.backend = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
94 }
95 self.backend
96 .as_mut()
97 .expect("clipboard initialized")
98 .set_text(text)
99 .map_err(|e| e.to_string())
100 })
101 }
102}
103
104pub fn snapshot_text(buffer: &Buffer) -> String {
107 let area = buffer.area;
108 (area.y..area.bottom())
109 .map(|y| {
110 let mut line = String::new();
111 let mut x = area.x;
112 while x < area.right() {
113 let symbol = buffer[(x, y)].symbol();
114 line.push_str(symbol);
115 let width = Span::raw(symbol)
116 .width()
117 .max(1)
118 .min((area.right() - x) as usize);
119 x += width as u16;
120 }
121 line.trim_end().to_string()
122 })
123 .collect::<Vec<_>>()
124 .join("\n")
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use ratatui::{layout::Rect, style::Style};
131
132 #[test]
133 fn snapshots_preserve_unicode_without_extra_wide_character_spaces() {
134 let mut buffer = Buffer::empty(Rect::new(3, 7, 18, 3));
135 buffer.set_string(3, 7, "界 e\u{301} ✅", Style::default());
136 buffer.set_string(3, 8, " indented ", Style::default());
137 assert_eq!(snapshot_text(&buffer), "界 e\u{301} ✅\n indented\n");
138 assert_eq!(snapshot_text(&Buffer::empty(Rect::new(0, 0, 0, 0))), "");
139 }
140
141 #[test]
142 fn text_and_snapshots_share_the_writer_and_report_results() {
143 let mut state = ClipboardState::default();
144 let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 1));
145 buffer.set_string(0, 0, "screen", Style::default());
146 state.request_text("arn:test");
147 assert!(state.complete_with(&buffer, |text| {
148 assert_eq!(text, "arn:test");
149 Ok(())
150 }));
151 assert_eq!(state.notice().unwrap().message, "Copied to clipboard");
152 assert!(!state.notice().unwrap().failed);
153 assert!(!state.complete_with(&buffer, |_| panic!("No second write")));
154 state.request_snapshot();
155 assert!(state.snapshot_requested());
156 state.complete_with(&buffer, |text| {
157 assert_eq!(text, "screen");
158 Ok(())
159 });
160 assert!(!state.snapshot_requested());
161 assert_eq!(
162 state.notice().unwrap().message,
163 "Snapshot copied to clipboard"
164 );
165 state.request_text("secret-not-in-error-message");
166 state.complete_with(&buffer, |_| Err("clipboard unavailable".into()));
167 assert!(state.notice().unwrap().failed);
168 assert_eq!(
169 state.notice().unwrap().message,
170 "Failed to copy: clipboard unavailable"
171 );
172 }
173
174 #[test]
175 fn copy_notices_expire_and_latest_request_wins() {
176 let mut state = ClipboardState::default();
177 let buffer = Buffer::empty(Rect::new(0, 0, 0, 0));
178 state.request_snapshot();
179 state.request_text("latest");
180 assert!(!state.snapshot_requested());
181 state.complete_with(&buffer, |text| {
182 assert_eq!(text, "latest");
183 Ok(())
184 });
185 state.notice.as_mut().unwrap().created = Instant::now() - NOTICE_DURATION;
186 assert!(state.notice().is_none());
187 assert!(state.expire_notice());
188 assert!(!state.expire_notice());
189 }
190}
191
192#[cfg(test)]
193mod app_tests {
194 use super::*;
195 use crate::app::{App, Service};
196 use crate::keymap::{Action, Mode};
197 use ratatui::{backend::TestBackend, layout::Rect, Terminal};
198
199 #[test]
200 fn clipboard_service_yanks_keep_payloads_and_do_not_replace_aws_errors() {
201 let mut app = App::new_without_client("test".into(), None);
202 app.current_service = Service::DynamoDbTables;
203 app.service_selected = true;
204 app.mode = Mode::Normal;
205 app.dynamodb_state.tables.items = vec![rusticity_core::dynamodb::DdbTable {
206 name: "orders".into(),
207 table_arn: "arn:aws:dynamodb:us-east-2:123456789012:table/orders".into(),
208 ..Default::default()
209 }];
210 let buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
211 app.handle_action(Action::Yank);
212 app.clipboard.complete_with(&buffer, |text| {
213 assert_eq!(text, "orders");
214 Ok(())
215 });
216 app.dynamodb_state.current_table = Some("orders".into());
217 app.handle_action(Action::Yank);
218 app.clipboard.complete_with(&buffer, |text| {
219 assert_eq!(text, "arn:aws:dynamodb:us-east-2:123456789012:table/orders");
220 Ok(())
221 });
222 app.mode = Mode::ErrorModal;
223 app.error_message = Some("AWS access denied".into());
224 app.handle_action(Action::Yank);
225 app.clipboard.complete_with(&buffer, |text| {
226 assert_eq!(text, "AWS access denied");
227 Ok(())
228 });
229 assert_eq!(app.error_message.as_deref(), Some("AWS access denied"));
230 assert_eq!(app.mode, Mode::ErrorModal);
231 app.clipboard.request_text("retry");
232 app.clipboard
233 .complete_with(&buffer, |_| Err("no clipboard".into()));
234 assert_eq!(app.error_message.as_deref(), Some("AWS access denied"));
235 assert_eq!(app.mode, Mode::ErrorModal);
236 }
237
238 #[test]
239 fn clipboard_notices_render_in_status_bar_without_opening_a_modal() {
240 let mut app = App::new_without_client("test".into(), None);
241 app.mode = Mode::Normal;
242 let buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
243 for failed in [false, true] {
244 app.clipboard.request_text("data");
245 app.clipboard.complete_with(&buffer, |_| {
246 if failed {
247 Err("unavailable".into())
248 } else {
249 Ok(())
250 }
251 });
252 let mut terminal = Terminal::new(TestBackend::new(160, 25)).unwrap();
253 terminal
254 .draw(|frame| crate::ui::render(frame, &app))
255 .unwrap();
256 let text: String = terminal
257 .backend()
258 .buffer()
259 .content()
260 .iter()
261 .map(|cell| cell.symbol())
262 .collect();
263 assert!(text.contains(if failed {
264 "Failed to copy: unavailable"
265 } else {
266 "Copied to clipboard"
267 }));
268 assert_eq!(app.mode, Mode::Normal);
269 assert!(app.error_message.is_none());
270 }
271 }
272
273 #[test]
274 fn clipboard_inventory_actions_and_print_use_the_same_request_state() {
275 let mut app = App::new_without_client("test".into(), None);
276 app.service_selected = true;
277 app.current_service = Service::VpcVpcs;
278 app.mode = Mode::Normal;
279 app.vpc_state.vpcs.items = vec![rusticity_core::vpc::Vpc {
280 vpc_id: "vpc-test".into(),
281 arn: "arn:vpc:test".into(),
282 ..Default::default()
283 }];
284 let buffer = Buffer::empty(Rect::new(0, 0, 1, 1));
285 app.handle_action(Action::Yank);
286 assert!(!app.clipboard.snapshot_requested());
287 app.clipboard.complete_with(&buffer, |text| {
288 assert_eq!(text, "arn:vpc:test");
289 Ok(())
290 });
291 app.handle_action(Action::CopyToClipboard);
292 assert!(app.clipboard.snapshot_requested());
293 app.clipboard.complete_with(&buffer, |text| {
294 assert_eq!(text, "");
295 Ok(())
296 });
297 assert!(!app.clipboard.snapshot_requested());
298 }
299}