1use crate::{
4 app::{App, Service},
5 keymap::{Action, Mode},
6 s3::Object,
7 ui::{self, s3::ObjectTab},
8};
9use ratatui::{prelude::*, widgets::Paragraph};
10use rusticity_core::{
11 s3::object::{TextFormat, TextPreview},
12 S3Client,
13};
14use std::{
15 path::PathBuf,
16 time::{Duration, Instant},
17};
18use tokio::task::JoinHandle;
19
20pub struct Preview {
21 pub key: String,
22 pub content: TextPreview,
23 pub scroll: usize,
24}
25struct Request {
26 bucket: String,
27 region: String,
28 key: String,
29 destination: Option<PathBuf>,
30 scope: String,
31}
32enum Output {
33 Preview(TextPreview),
34 Download(PathBuf),
35}
36struct Running {
37 task: JoinHandle<Result<Output, String>>,
38 key: String,
39 scope: String,
40}
41impl Drop for Running {
42 fn drop(&mut self) {
43 self.task.abort();
44 }
45}
46
47pub struct Notice {
48 pub message: String,
49 pub failed: bool,
50 at: Instant,
51}
52#[derive(Default)]
53pub struct State {
54 pub preview: Option<Preview>,
55 pub notice: Option<Notice>,
56 pending: Option<Request>,
57 running: Option<Running>,
58 scope: String,
59}
60impl State {
61 pub fn busy(&self) -> bool {
62 self.pending.is_some() || self.running.is_some()
63 }
64 fn notify(&mut self, message: impl Into<String>, failed: bool) {
65 self.notice = Some(Notice {
66 message: message.into(),
67 failed,
68 at: Instant::now(),
69 });
70 }
71 fn cancel(&mut self) {
72 self.pending = None;
73 self.running = None;
74 }
75}
76
77pub fn active(app: &App) -> bool {
78 app.service_selected
79 && app.current_service == Service::S3Buckets
80 && app.s3_state.current_bucket.is_some()
81 && app.s3_state.object_tab == ObjectTab::Objects
82}
83fn scope(app: &App) -> String {
84 format!(
85 "{}|{}|{}|{}|{:?}|{:?}",
86 app.profile,
87 app.config.region,
88 app.config.account_id,
89 app.config.role_arn,
90 app.s3_state.current_bucket,
91 app.s3_state.prefix_stack
92 )
93}
94
95pub fn visible_objects(state: &ui::s3::State) -> Vec<&Object> {
98 fn append<'a>(obj: &'a Object, state: &'a ui::s3::State, rows: &mut Vec<&'a Object>) {
99 rows.push(obj);
100 if obj.is_prefix && state.expanded_prefixes.contains(&obj.key) {
101 if let Some(children) = state.prefix_preview.get(&obj.key) {
102 for child in children {
103 append(child, state, rows);
104 }
105 }
106 }
107 }
108 let mut rows = vec![];
109 let prefix = state
110 .prefix_stack
111 .last()
112 .map(String::as_str)
113 .unwrap_or_default();
114 let filter = state.object_filter.to_lowercase();
115 for object in state.objects.iter().filter(|object| {
116 object
117 .key
118 .strip_prefix(prefix)
119 .unwrap_or(&object.key)
120 .to_lowercase()
121 .contains(&filter)
122 }) {
123 append(object, state, &mut rows);
124 }
125 rows
126}
127
128pub fn selected(app: &App) -> Option<&Object> {
129 visible_objects(&app.s3_state)
130 .get(app.s3_state.selected_object)
131 .copied()
132}
133
134fn request(app: &mut App, download: bool) {
135 if app.s3_state.object_view.busy() {
136 app.s3_state
137 .object_view
138 .notify("Object transfer in progress; Esc cancels", false);
139 return;
140 }
141 let Some(object) = selected(app).filter(|object| !object.is_prefix) else {
142 return;
143 };
144 let key = object.key.clone();
145 let bucket = app
146 .s3_state
147 .current_bucket
148 .clone()
149 .expect("active object view");
150 let region = app
151 .s3_state
152 .buckets
153 .items
154 .iter()
155 .find(|item| item.name == bucket)
156 .map(|item| item.region.clone())
157 .unwrap_or_default();
158 let destination = if download {
159 match std::env::current_dir() {
160 Ok(path) => Some(path),
161 Err(error) => {
162 app.s3_state.object_view.notify(error.to_string(), true);
163 return;
164 }
165 }
166 } else {
167 None
168 };
169 let scope = scope(app);
170 let state = &mut app.s3_state.object_view;
171 state.scope = scope.clone();
172 state.notify(
173 if download {
174 "Downloading object… Esc cancels"
175 } else {
176 "Loading text preview… Esc cancels"
177 },
178 false,
179 );
180 state.pending = Some(Request {
181 bucket,
182 region,
183 key,
184 destination,
185 scope,
186 });
187}
188
189pub fn handle_action(app: &mut App, action: &Action) -> bool {
191 if !active(app) || app.mode != Mode::Normal {
192 return false;
193 }
194 if app.s3_state.object_view.preview.is_some() {
195 let preview = app.s3_state.object_view.preview.as_mut().unwrap();
196 let max = preview.content.text.lines().count().saturating_sub(1);
197 match action {
198 Action::NextItem => preview.scroll = preview.scroll.saturating_add(1).min(max),
199 Action::PrevItem => preview.scroll = preview.scroll.saturating_sub(1),
200 Action::PageDown => preview.scroll = preview.scroll.saturating_add(20).min(max),
201 Action::PageUp => preview.scroll = preview.scroll.saturating_sub(20),
202 Action::GoBack => {
203 app.s3_state.object_view.preview = None;
204 }
205 Action::Yank => app.clipboard.request_text(&preview.content.text),
206 Action::Quit
209 | Action::ConfirmQuit
210 | Action::CloseService
211 | Action::NextTab
212 | Action::PrevTab
213 | Action::OpenSpaceMenu
214 | Action::ShowHelp
215 | Action::CopyToClipboard => return false,
216 _ => {}
217 }
218 return true;
219 }
220 match action {
221 Action::PageDown => {
222 request(app, true);
223 true
224 }
225 Action::Select if selected(app).is_some_and(|object| !object.is_prefix) => {
226 request(app, false);
227 true
228 }
229 Action::GoBack if app.s3_state.object_view.busy() => {
230 app.s3_state.object_view.cancel();
231 app.s3_state
232 .object_view
233 .notify("Object transfer cancelled", false);
234 true
235 }
236 _ => false,
237 }
238}
239
240pub async fn poll(app: &mut App) -> bool {
242 let current_scope = scope(app);
243 let state = &mut app.s3_state.object_view;
244 if !state.scope.is_empty()
245 && (state.scope != current_scope
246 || !app.service_selected
247 || app.current_service != Service::S3Buckets
248 || app.s3_state.object_tab != ObjectTab::Objects)
249 {
250 *state = State::default();
251 return true;
252 }
253 let mut changed = false;
254 if state
255 .notice
256 .as_ref()
257 .is_some_and(|notice| notice.at.elapsed() >= Duration::from_secs(8))
258 && !state.busy()
259 {
260 state.notice = None;
261 changed = true;
262 }
263 if let Some(request) = state.pending.take() {
264 let config = app.config.clone();
265 let key = request.key.clone();
266 let scope = request.scope.clone();
267 let task = tokio::spawn(async move {
268 let client = S3Client::new(config);
269 let operation = async {
270 let region = if request.region.is_empty() {
271 client.get_bucket_location(&request.bucket).await?
272 } else {
273 request.region
274 };
275 if let Some(directory) = request.destination {
276 client
277 .download_object(&request.bucket, ®ion, &request.key, &directory)
278 .await
279 .map(Output::Download)
280 } else {
281 client
282 .preview_object(&request.bucket, ®ion, &request.key)
283 .await
284 .map(Output::Preview)
285 }
286 };
287 match tokio::time::timeout(Duration::from_secs(300), operation).await {
288 Ok(result) => result.map_err(|error| format!("{error:#}")),
289 Err(_) => Err("Object transfer timed out".into()),
290 }
291 });
292 state.running = Some(Running { task, key, scope });
293 changed = true;
294 }
295 if state
296 .running
297 .as_ref()
298 .is_some_and(|running| running.task.is_finished())
299 {
300 let mut running = state.running.take().unwrap();
301 let result = (&mut running.task).await;
302 if running.scope != current_scope {
303 return true;
304 }
305 match result {
306 Ok(Ok(Output::Preview(content))) => {
307 state.preview = Some(Preview {
308 key: running.key.clone(),
309 content,
310 scroll: 0,
311 });
312 state.notice = None;
313 }
314 Ok(Ok(Output::Download(path))) => {
315 state.notify(format!("Downloaded to {}", path.display()), false)
316 }
317 Ok(Err(error)) => state.notify(error, true),
318 Err(error) => state.notify(format!("Object transfer failed: {error}"), true),
319 }
320 changed = true;
321 }
322 changed
323}
324
325pub fn render_preview(frame: &mut Frame, app: &App, area: Rect) -> bool {
326 let Some(preview) = &app.s3_state.object_view.preview else {
327 return false;
328 };
329 let title = format!(
330 "{}{}",
331 preview
332 .key
333 .chars()
334 .filter(|c| !c.is_control())
335 .collect::<String>(),
336 if preview.content.truncated {
337 " — preview truncated at 1 MiB"
338 } else {
339 ""
340 }
341 );
342 if preview.content.text.is_empty() {
343 frame.render_widget(
344 Paragraph::new("Empty text object").block(ui::titled_block(&title)),
345 area,
346 );
347 } else {
348 let render = match preview.content.format {
349 TextFormat::Json => ui::render_json_highlighted,
350 TextFormat::Yaml => ui::render_yaml_highlighted,
351 TextFormat::Plain => ui::render_plain_text,
352 };
353 render(
354 frame,
355 area,
356 &preview.content.text,
357 preview.scroll,
358 &title,
359 true,
360 );
361 }
362 true
363}
364
365#[cfg(test)]
366mod tests;