Skip to main content

rusticity_term/s3/
object.rs

1//! S3 object actions. Transfer futures are owned, cancelable, and never awaited
2//! by key handlers. This reuses the common JSON/YAML/plain text viewer.
3use 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
95/// The same visible ordering as the objects table: filter root rows, then append
96/// expanded descendants. Loading prefixes have no selectable object yet.
97pub 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
189/// True means the S3 object view handled the action; don't run generic loaders.
190pub 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            // Keep app-wide close/quit/tab controls available; service controls must
207            // not mutate the underlying object list while previewing content.
208            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
240/// Poll only finished tasks; no UI input waits for an S3 response or file write.
241pub 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, &region, &request.key, &directory)
278                        .await
279                        .map(Output::Download)
280                } else {
281                    client
282                        .preview_object(&request.bucket, &region, &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;