diff --git a/src/app.rs b/src/app.rs index 9695c32..a66412a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -28,7 +28,9 @@ use crate::{ }, file::{copy_image_to_clipboard, copy_text_to_clipboard, create_binary_file, save_error_log}, keys::UserEventMapper, - object::{AppObjects, DownloadObjectInfo, FileDetail, ObjectItem, ObjectKey, RawObject}, + object::{ + AppObjects, BucketItem, DownloadObjectInfo, FileDetail, ObjectItem, ObjectKey, RawObject, + }, pages::page::{Page, PageStack}, widget::{Header, LoadingDialog, Status, StatusType}, }; @@ -69,6 +71,7 @@ pub struct App { notification: Notification, is_loading: bool, + pending_single_bucket_reload: Option, } impl App { @@ -83,6 +86,7 @@ impl App { tx, notification: Notification::None, is_loading: true, + pending_single_bucket_reload: None, } } @@ -154,8 +158,59 @@ impl App { } pub fn complete_reload_buckets(&mut self, result: Result) { - // current bucket list page is popped inside complete_initialize - self.complete_initialize(result.map(|r| r.into())); + let view_state = match self.page_stack.current_page() { + Page::BucketList(page) => page.view_state_snapshot(), + page => { + tracing::warn!( + current_page = ?page, + "ignoring CompleteReloadBuckets because current page is not BucketList" + ); + self.pending_single_bucket_reload = None; + self.is_loading = false; + return; + } + }; + + match result { + Ok(CompleteReloadBucketsResult { buckets }) => { + if buckets.is_empty() { + self.pending_single_bucket_reload = None; + + let msg = format!("No bucket found (region: {})", self.client.region()); + self.tx.send(AppEventType::NotifyWarn(msg)); + + self.is_loading = false; + return; + } else if buckets.len() == 1 { + let bucket = buckets.into_iter().next().unwrap(); + let object_key = ObjectKey::bucket(&bucket.name); + + self.pending_single_bucket_reload = Some(bucket); + self.tx.send(AppEventType::LoadObjects(object_key)); + self.is_loading = true; + return; + } + + self.pending_single_bucket_reload = None; + self.app_objects.set_bucket_items(buckets); + + let bucket_items = self.app_objects.get_bucket_items(); + + let mut bucket_list_page = + Page::of_bucket_list(bucket_items, Rc::clone(&self.ctx), self.tx.clone()); + if let Page::BucketList(page) = &mut bucket_list_page { + page.restore_view_state(view_state); + } + self.page_stack.pop(); + self.page_stack.push(bucket_list_page); + } + Err(e) => { + self.pending_single_bucket_reload = None; + self.tx.send(AppEventType::NotifyError(e)); + } + } + + self.is_loading = false; } pub fn bucket_list_move_down(&mut self, object_key: ObjectKey) { @@ -175,7 +230,9 @@ impl App { } pub fn bucket_list_refresh(&mut self) { + let bucket_items = self.app_objects.get_bucket_items(); self.app_objects.clear_all(); + self.app_objects.set_bucket_items(bucket_items); self.tx.send(AppEventType::ReloadBuckets); self.is_loading = true; @@ -264,6 +321,35 @@ impl App { pub fn complete_load_objects(&mut self, result: Result) { match result { Ok(CompleteLoadObjectsResult { items, object_key }) => { + let bucket_name = object_key.bucket_name.clone(); + if let Some(bucket) = self.pending_single_bucket_reload.take() { + if bucket.name != bucket_name { + tracing::warn!( + pending_bucket = %bucket.name, + loaded_bucket = %bucket_name, + "ignoring CompleteLoadObjects because pending single-bucket reload targets a different bucket" + ); + self.is_loading = false; + return; + } + + match self.page_stack.current_page() { + Page::BucketList(_) => { + self.app_objects.set_bucket_items(vec![bucket]); + self.page_stack.pop(); + } + page => { + tracing::warn!( + current_page = ?page, + bucket = %bucket_name, + "ignoring CompleteLoadObjects because pending single-bucket reload is no longer on BucketList" + ); + self.is_loading = false; + return; + } + } + } + self.app_objects .set_object_items(object_key.clone(), items.clone()); @@ -272,6 +358,7 @@ impl App { self.page_stack.push(object_list_page); } Err(e) => { + self.pending_single_bucket_reload = None; self.tx.send(AppEventType::NotifyError(e)); } } @@ -294,8 +381,37 @@ impl App { } pub fn complete_reload_objects(&mut self, result: Result) { - self.page_stack.pop(); - self.complete_load_objects(result.map(|r| r.into())); + let view_state = match self.page_stack.current_page() { + Page::ObjectList(page) => page.view_state_snapshot(), + page => { + tracing::warn!( + current_page = ?page, + "ignoring CompleteReloadObjects because current page is not ObjectList" + ); + self.is_loading = false; + return; + } + }; + + match result { + Ok(CompleteReloadObjectsResult { items, object_key }) => { + self.app_objects + .set_object_items(object_key.clone(), items.clone()); + + let mut object_list_page = + Page::of_object_list(items, object_key, Rc::clone(&self.ctx), self.tx.clone()); + if let Page::ObjectList(page) = &mut object_list_page { + page.restore_view_state(view_state); + } + self.page_stack.pop(); + self.page_stack.push(object_list_page); + } + Err(e) => { + self.tx.send(AppEventType::NotifyError(e)); + } + } + + self.is_loading = false; } pub fn load_object_detail(&self) { @@ -937,3 +1053,514 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use chrono::{DateTime, Local, NaiveDateTime}; + use ratatui::{ + backend::TestBackend, + crossterm::event::{KeyCode, KeyEvent}, + Terminal, + }; + + use super::*; + use crate::{ + client::AddressingStyle, + event::{ + CompleteInitializeResult, CompleteLoadObjectsResult, CompleteReloadBucketsResult, + CompleteReloadObjectsResult, + }, + keys::UserEvent, + object::{BucketItem, ObjectItem, ObjectKey}, + pages::page::Page, + }; + + #[tokio::test] + async fn bucket_list_refresh_keeps_view_state() { + let (mut app, _rx) = app().await; + let buckets = vec![ + bucket_item("foo-bucket"), + bucket_item("bar-bucket"), + bucket_item("baz-bucket"), + bucket_item("qux-bucket"), + ]; + app.complete_initialize(Ok(CompleteInitializeResult { + buckets: buckets.clone(), + prefix: None, + })); + + apply_filter( + app.page_stack.current_page_mut(), + UserEvent::BucketListFilter, + &[KeyCode::Char('b'), KeyCode::Char('a')], + ); + apply_sort( + app.page_stack.current_page_mut(), + UserEvent::BucketListSort, + 2, + ); + + app.complete_reload_buckets(Ok(CompleteReloadBucketsResult { buckets })); + + assert_eq!(app.page_stack.len(), 1); + assert_bucket_selected(&app, "baz-bucket"); + } + + #[tokio::test] + async fn bucket_list_refresh_with_zero_buckets_keeps_bucket_list_visible_and_retryable() { + let (mut app, mut rx) = app().await; + let buckets = vec![bucket_item("foo-bucket"), bucket_item("bar-bucket")]; + let expected_warning = format!("No bucket found (region: {})", app.client.region()); + + app.complete_initialize(Ok(CompleteInitializeResult { + buckets, + prefix: None, + })); + + app.bucket_list_refresh(); + assert_reload_buckets_requested(&mut rx).await; + + app.complete_reload_buckets(Ok(CompleteReloadBucketsResult { buckets: vec![] })); + + assert!(!app.loading()); + assert!(app.pending_single_bucket_reload.is_none()); + assert_eq!(app.page_stack.len(), 1); + assert!(matches!(app.page_stack.current_page(), Page::BucketList(_))); + assert_bucket_selected(&app, "foo-bucket"); + assert_bucket_names(&app, &["foo-bucket", "bar-bucket"]); + assert_notify_warn(&mut rx, &expected_warning).await; + + app.page_stack.current_page_mut().handle_user_events( + vec![UserEvent::BucketListRefresh], + KeyEvent::from(KeyCode::Char('r')), + ); + assert_bucket_list_refresh_requested(&mut rx).await; + } + + #[tokio::test] + async fn bucket_list_refresh_with_single_bucket_waits_for_object_load_before_replacing_page() { + let (mut app, mut rx) = app().await; + let buckets = vec![bucket_item("foo-bucket"), bucket_item("bar-bucket")]; + let bucket = bucket_item("solo-bucket"); + let items = vec![object_file_item("foo.txt")]; + + app.complete_initialize(Ok(CompleteInitializeResult { + buckets, + prefix: None, + })); + + app.bucket_list_refresh(); + assert_reload_buckets_requested(&mut rx).await; + + app.complete_reload_buckets(Ok(CompleteReloadBucketsResult { + buckets: vec![bucket.clone()], + })); + + assert!(app.loading()); + assert_eq!(app.page_stack.len(), 1); + assert!(matches!(app.page_stack.current_page(), Page::BucketList(_))); + assert_bucket_selected(&app, "foo-bucket"); + assert_load_objects_requested(&mut rx, "solo-bucket").await; + + app.complete_load_objects(Ok(CompleteLoadObjectsResult { + items, + object_key: ObjectKey::bucket(&bucket.name), + })); + + assert!(!app.loading()); + assert!(app.pending_single_bucket_reload.is_none()); + assert_eq!(app.page_stack.len(), 1); + assert_object_selected(&app, "foo.txt"); + assert_bucket_names(&app, &["solo-bucket"]); + } + + #[tokio::test] + async fn bucket_list_refresh_with_single_bucket_keeps_bucket_list_visible_on_object_load_error() + { + let (mut app, mut rx) = app().await; + let buckets = vec![bucket_item("foo-bucket"), bucket_item("bar-bucket")]; + + app.complete_initialize(Ok(CompleteInitializeResult { + buckets, + prefix: None, + })); + + app.bucket_list_refresh(); + assert_reload_buckets_requested(&mut rx).await; + + app.complete_reload_buckets(Ok(CompleteReloadBucketsResult { + buckets: vec![bucket_item("solo-bucket")], + })); + assert_load_objects_requested(&mut rx, "solo-bucket").await; + + app.complete_load_objects(Err(AppError::msg("Failed to load objects"))); + + assert!(!app.loading()); + assert!(app.pending_single_bucket_reload.is_none()); + assert_eq!(app.page_stack.len(), 1); + assert!(matches!(app.page_stack.current_page(), Page::BucketList(_))); + assert!(!matches!( + app.page_stack.current_page(), + Page::Initializing(_) + )); + assert_bucket_selected(&app, "foo-bucket"); + assert_bucket_names(&app, &["foo-bucket", "bar-bucket"]); + assert_notify_error(&mut rx, "Failed to load objects").await; + } + + #[tokio::test] + async fn complete_reload_buckets_ignores_response_when_current_page_is_not_bucket_list() { + let (mut app, _rx) = app().await; + let object_key = ObjectKey::bucket("test-bucket"); + let items = vec![object_file_item("foo.txt")]; + + app.page_stack.push(Page::of_object_list( + items, + object_key, + Rc::clone(&app.ctx), + app.tx.clone(), + )); + app.is_loading = true; + + app.complete_reload_buckets(Ok(CompleteReloadBucketsResult { + buckets: vec![bucket_item("foo-bucket")], + })); + + assert!(!app.loading()); + assert!(app.pending_single_bucket_reload.is_none()); + assert_eq!(app.page_stack.len(), 1); + assert!(matches!(app.page_stack.current_page(), Page::ObjectList(_))); + assert!(app.app_objects.get_bucket_items().is_empty()); + } + + #[tokio::test] + async fn complete_load_objects_ignores_mismatched_pending_single_bucket_reload() { + let (mut app, _rx) = app().await; + let buckets = vec![bucket_item("foo-bucket"), bucket_item("bar-bucket")]; + let object_key = ObjectKey::bucket("other-bucket"); + let items = vec![object_file_item("foo.txt")]; + + app.complete_initialize(Ok(CompleteInitializeResult { + buckets, + prefix: None, + })); + app.pending_single_bucket_reload = Some(bucket_item("solo-bucket")); + app.is_loading = true; + + app.complete_load_objects(Ok(CompleteLoadObjectsResult { + items, + object_key: object_key.clone(), + })); + + assert!(!app.loading()); + assert!(app.pending_single_bucket_reload.is_none()); + assert_eq!(app.page_stack.len(), 1); + assert!(matches!(app.page_stack.current_page(), Page::BucketList(_))); + assert_bucket_selected(&app, "foo-bucket"); + assert_bucket_names(&app, &["foo-bucket", "bar-bucket"]); + assert!(app.app_objects.get_object_items(&object_key).is_none()); + } + + #[tokio::test] + async fn object_list_refresh_keeps_view_state() { + let (mut app, _rx) = app().await; + let object_key = ObjectKey::bucket("test-bucket"); + let items = vec![ + object_file_item("foo.txt"), + object_file_item("bar.txt"), + object_file_item("baz.txt"), + object_file_item("qux.txt"), + ]; + + app.page_stack.push(Page::of_object_list( + items.clone(), + object_key.clone(), + Rc::clone(&app.ctx), + app.tx.clone(), + )); + app.is_loading = false; + + apply_filter( + app.page_stack.current_page_mut(), + UserEvent::ObjectListFilter, + &[KeyCode::Char('b'), KeyCode::Char('a')], + ); + apply_sort( + app.page_stack.current_page_mut(), + UserEvent::ObjectListSort, + 2, + ); + + app.complete_reload_objects(Ok(CompleteReloadObjectsResult { items, object_key })); + + assert_eq!(app.page_stack.len(), 1); + assert_object_selected(&app, "baz.txt"); + } + + #[tokio::test] + async fn complete_reload_objects_ignores_response_when_current_page_is_not_object_list() { + let (mut app, _rx) = app().await; + let buckets = vec![bucket_item("foo-bucket"), bucket_item("bar-bucket")]; + + app.complete_initialize(Ok(CompleteInitializeResult { + buckets, + prefix: None, + })); + app.is_loading = true; + + app.complete_reload_objects(Ok(CompleteReloadObjectsResult { + items: vec![object_file_item("foo.txt")], + object_key: ObjectKey::bucket("foo-bucket"), + })); + + assert!(!app.loading()); + assert_eq!(app.page_stack.len(), 1); + assert!(matches!(app.page_stack.current_page(), Page::BucketList(_))); + assert!(app + .app_objects + .get_object_items(&ObjectKey::bucket("foo-bucket")) + .is_none()); + } + + #[tokio::test] + async fn object_list_refresh_keeps_scroll_offset( + ) -> std::result::Result<(), core::convert::Infallible> { + let (mut app, _rx) = app().await; + let object_key = ObjectKey::bucket("test-bucket"); + let items = (1..=16) + .map(|i| object_file_item(&format!("file{i}.txt"))) + .collect(); + + app.page_stack.push(Page::of_object_list( + items, + object_key.clone(), + Rc::clone(&app.ctx), + app.tx.clone(), + )); + app.is_loading = false; + + let mut terminal = setup_terminal()?; + render_app(&mut app, &mut terminal)?; + + for _ in 0..8 { + app.page_stack.current_page_mut().handle_user_events( + vec![UserEvent::ObjectListDown], + KeyEvent::from(KeyCode::Char('j')), + ); + } + + let reloaded_items = vec![ + object_file_item("file1.txt"), + object_file_item("file2.txt"), + object_file_item("file3.txt"), + object_file_item("file4.txt"), + object_file_item("file5.txt"), + object_file_item("file6.txt"), + object_file_item("file7.txt"), + object_file_item("file8.txt"), + object_file_item("file8.5.txt"), + object_file_item("file9.txt"), + object_file_item("file10.txt"), + object_file_item("file11.txt"), + object_file_item("file12.txt"), + object_file_item("file13.txt"), + object_file_item("file14.txt"), + object_file_item("file15.txt"), + object_file_item("file16.txt"), + ]; + + app.complete_reload_objects(Ok(CompleteReloadObjectsResult { + items: reloaded_items, + object_key, + })); + render_app(&mut app, &mut terminal)?; + + assert_eq!(app.page_stack.len(), 1); + assert_object_selected(&app, "file9.txt"); + assert_object_list_position(&app, 9, 5); + + Ok(()) + } + + async fn app() -> (App, tokio::sync::mpsc::UnboundedReceiver) { + let client = Client::new( + None, + None, + None, + "us-east-1".to_string(), + AddressingStyle::Auto, + true, + ) + .await; + let ctx = AppContext::default(); + let mapper = UserEventMapper::default(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let app = App::new(mapper, client, ctx, Sender::new(tx)); + (app, rx) + } + + async fn assert_reload_buckets_requested( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ) { + match rx.recv().await { + Some(AppEventType::ReloadBuckets) => {} + event => panic!("Invalid event: {event:?}"), + } + } + + async fn assert_bucket_list_refresh_requested( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ) { + match rx.recv().await { + Some(AppEventType::BucketListRefresh) => {} + event => panic!("Invalid event: {event:?}"), + } + } + + async fn assert_load_objects_requested( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + expected_bucket: &str, + ) { + match rx.recv().await { + Some(AppEventType::LoadObjects(object_key)) => { + assert_eq!(object_key, ObjectKey::bucket(expected_bucket)); + } + event => panic!("Invalid event: {event:?}"), + } + } + + async fn assert_notify_error( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + expected_message: &str, + ) { + match rx.recv().await { + Some(AppEventType::NotifyError(e)) => { + assert_eq!(e.msg, expected_message); + } + event => panic!("Invalid event: {event:?}"), + } + } + + async fn assert_notify_warn( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + expected_message: &str, + ) { + match rx.recv().await { + Some(AppEventType::NotifyWarn(msg)) => { + assert_eq!(msg, expected_message); + } + event => panic!("Invalid event: {event:?}"), + } + } + + fn apply_filter(page: &mut Page, open_event: UserEvent, chars: &[KeyCode]) { + page.handle_user_events(vec![open_event], KeyEvent::from(KeyCode::Char('/'))); + for &key in chars { + page.handle_user_events(vec![], KeyEvent::from(key)); + } + page.handle_user_events( + vec![UserEvent::InputDialogApply], + KeyEvent::from(KeyCode::Enter), + ); + } + + fn apply_sort(page: &mut Page, open_event: UserEvent, move_down_count: usize) { + page.handle_user_events(vec![open_event], KeyEvent::from(KeyCode::Char('o'))); + for _ in 0..move_down_count { + page.handle_user_events( + vec![UserEvent::SelectDialogDown], + KeyEvent::from(KeyCode::Char('j')), + ); + } + page.handle_user_events( + vec![UserEvent::SelectDialogSelect], + KeyEvent::from(KeyCode::Enter), + ); + } + + fn render_app( + app: &mut App, + terminal: &mut Terminal, + ) -> std::result::Result<(), core::convert::Infallible> { + terminal.draw(|f| app.render(f))?; + Ok(()) + } + + fn assert_bucket_selected(app: &App, expected: &str) { + match app.page_stack.current_page() { + Page::BucketList(page) => assert_eq!(page.current_selected_item().name, expected), + page => panic!("Invalid page: {page:?}"), + } + } + + fn assert_object_selected(app: &App, expected: &str) { + match app.page_stack.current_page() { + Page::ObjectList(page) => assert_eq!(page.current_selected_item().name(), expected), + page => panic!("Invalid page: {page:?}"), + } + } + + fn assert_object_list_position(app: &App, selected: usize, offset: usize) { + match app.page_stack.current_page() { + Page::ObjectList(page) => { + let list_state = page.list_state(); + assert_eq!(list_state.selected, selected); + assert_eq!(list_state.offset, offset); + } + page => panic!("Invalid page: {page:?}"), + } + } + + fn assert_bucket_names(app: &App, expected: &[&str]) { + let bucket_names = app + .app_objects + .get_bucket_items() + .into_iter() + .map(|bucket| bucket.name) + .collect::>(); + let expected = expected + .iter() + .map(|bucket| bucket.to_string()) + .collect::>(); + + assert_eq!(bucket_names, expected); + } + + fn bucket_item(name: &str) -> BucketItem { + BucketItem { + name: name.to_string(), + s3_uri: String::new(), + arn: String::new(), + object_url: String::new(), + } + } + + fn parse_datetime(s: &str) -> DateTime { + NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .unwrap() + .and_local_timezone(Local) + .unwrap() + } + + fn object_file_item(name: &str) -> ObjectItem { + ObjectItem::File { + name: name.to_string(), + size_byte: 1024, + last_modified: parse_datetime("2024-01-02 13:01:02"), + key: name.to_string(), + s3_uri: String::new(), + arn: String::new(), + object_url: String::new(), + e_tag: String::new(), + } + } + + fn setup_terminal() -> std::result::Result, core::convert::Infallible> { + let backend = TestBackend::new(80, 12); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + Ok(terminal) + } +} diff --git a/src/object.rs b/src/object.rs index 960554a..13f7d8a 100644 --- a/src/object.rs +++ b/src/object.rs @@ -41,6 +41,13 @@ impl ObjectItem { } } + pub fn key(&self) -> &str { + match self { + ObjectItem::Dir { key, .. } => key, + ObjectItem::File { key, .. } => key, + } + } + pub fn size_byte(&self) -> Option { match self { ObjectItem::Dir { .. } => None, diff --git a/src/pages/bucket_list.rs b/src/pages/bucket_list.rs index db90063..e3b9fe2 100644 --- a/src/pages/bucket_list.rs +++ b/src/pages/bucket_list.rs @@ -46,6 +46,15 @@ pub struct BucketListPage { tx: Sender, } +#[derive(Debug)] +pub(crate) struct BucketListViewState { + filter: String, + sort: BucketListSortType, + selected_bucket_name: Option, + selected_index: usize, + selected_row: usize, +} + #[derive(Debug)] enum ViewState { Default, @@ -223,6 +232,8 @@ impl BucketListPage { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + self.list_state.prepare_for_render(area.height as usize); + let offset = self.list_state.offset; let selected = self.list_state.selected; @@ -577,6 +588,51 @@ impl BucketListPage { self.view_state = ViewState::Default; } + pub(crate) fn view_state_snapshot(&self) -> BucketListViewState { + let (selected_bucket_name, selected_index, selected_row) = if self.non_empty() { + ( + Some(self.current_selected_item().name.clone()), + self.list_state.selected, + self.list_state + .selected + .saturating_sub(self.list_state.offset), + ) + } else { + (None, 0, 0) + }; + + BucketListViewState { + filter: self.filter_input_state.input().to_string(), + sort: self.sort_dialog_state.selected(), + selected_bucket_name, + selected_index, + selected_row, + } + } + + pub(crate) fn restore_view_state(&mut self, state: BucketListViewState) { + self.sort_dialog_state.set_selected(state.sort); + self.filter_input_state = InputDialogState::new(state.filter); + self.filter_view_indices(); + + if self.view_indices.is_empty() { + return; + } + + let selected = state + .selected_bucket_name + .as_ref() + .and_then(|selected_bucket_name| { + self.view_indices + .iter() + .position(|&i| self.bucket_items[i].name == *selected_bucket_name) + }) + .unwrap_or_else(|| state.selected_index.min(self.view_indices.len() - 1)); + let offset = selected.saturating_sub(state.selected_row); + + self.list_state.set_position(selected, offset); + } + pub fn current_selected_item(&self) -> &BucketItem { let i = self .view_indices @@ -680,7 +736,7 @@ fn build_list_items<'a>( selected: usize, area: Rect, ) -> Vec> { - let show_item_count = (area.height as usize) - 2 /* border */; + let show_item_count = (area.height as usize).saturating_sub(2 /* border */); view_indices .iter() .map(|&original_idx| ¤t_items[original_idx]) @@ -1139,6 +1195,129 @@ mod tests { assert_eq!(page.view_indices, vec![0, 4]); } + #[tokio::test] + async fn test_restore_view_state_preserves_selected_bucket_and_scroll_position( + ) -> Result<(), core::convert::Infallible> { + let ctx = Rc::default(); + let tx = sender(); + let mut terminal = setup_terminal()?; + let area = Rect::new(0, 0, 30, 10); + + let items = (1..=12) + .map(|i| bucket_item(&format!("bucket{i}"))) + .collect(); + let mut page = BucketListPage::new(items, Rc::clone(&ctx), tx.clone()); + terminal.draw(|f| page.render(f, area))?; + page.list_state.set_position(5, 2); + + let state = page.view_state_snapshot(); + + let items = vec![ + bucket_item("bucket1"), + bucket_item("bucket2"), + bucket_item("bucket3"), + bucket_item("bucket4"), + bucket_item("bucket5"), + bucket_item("bucket5.5"), + bucket_item("bucket6"), + bucket_item("bucket7"), + bucket_item("bucket8"), + bucket_item("bucket9"), + bucket_item("bucket10"), + bucket_item("bucket11"), + bucket_item("bucket12"), + ]; + let mut restored = BucketListPage::new(items, ctx, tx); + restored.restore_view_state(state); + terminal.draw(|f| restored.render(f, area))?; + + assert_eq!(restored.current_selected_item().name, "bucket6"); + assert_eq!(restored.list_state.selected, 6); + assert_eq!(restored.list_state.offset, 3); + + Ok(()) + } + + #[tokio::test] + async fn test_restore_view_state_falls_back_to_previous_index_when_bucket_was_deleted( + ) -> Result<(), core::convert::Infallible> { + let ctx = Rc::default(); + let tx = sender(); + let mut terminal = setup_terminal()?; + let area = Rect::new(0, 0, 30, 10); + + let items = (1..=16) + .map(|i| bucket_item(&format!("bucket{i}"))) + .collect(); + let mut page = BucketListPage::new(items, Rc::clone(&ctx), tx.clone()); + terminal.draw(|f| page.render(f, area))?; + page.list_state.set_position(8, 5); + + let state = page.view_state_snapshot(); + + let items = (1..=16) + .filter(|&i| i != 9) + .map(|i| bucket_item(&format!("bucket{i}"))) + .collect(); + let mut restored = BucketListPage::new(items, ctx, tx); + restored.restore_view_state(state); + terminal.draw(|f| restored.render(f, area))?; + + assert_eq!(restored.current_selected_item().name, "bucket10"); + assert_eq!(restored.list_state.selected, 8); + assert_eq!(restored.list_state.offset, 5); + + Ok(()) + } + + #[tokio::test] + async fn test_render_after_restore_clamps_offset_before_building_items( + ) -> Result<(), core::convert::Infallible> { + let ctx = Rc::default(); + let tx = sender(); + let mut terminal = setup_terminal()?; + let area = Rect::new(0, 0, 30, 10); + + let items = (1..=16) + .map(|i| bucket_item(&format!("bucket{i}"))) + .collect(); + let mut page = BucketListPage::new(items, Rc::clone(&ctx), tx.clone()); + terminal.draw(|f| page.render(f, area))?; + page.list_state.set_position(8, 6); + + let state = page.view_state_snapshot(); + + let items = ["bucket1", "bucket2", "bucket3", "bucket9"] + .into_iter() + .map(bucket_item) + .collect(); + let mut restored = BucketListPage::new(items, ctx, tx); + restored.restore_view_state(state); + + terminal.draw(|f| restored.render(f, area))?; + + #[rustfmt::skip] + let mut expected = Buffer::with_lines([ + "┌───────────────────── 4 / 4 ┐", + "│ bucket1 │", + "│ bucket2 │", + "│ bucket3 │", + "│ bucket9 │", + "│ │", + "│ │", + "│ │", + "│ │", + "└────────────────────────────┘", + ]); + set_cells! { expected => + (2..28, [4]) => bg: Color::Cyan, fg: Color::Black, + } + + terminal.backend().assert_buffer(&expected); + + Ok(()) + } + fn setup_terminal() -> Result, core::convert::Infallible> { let backend = TestBackend::new(30, 10); let mut terminal = Terminal::new(backend)?; diff --git a/src/pages/object_list.rs b/src/pages/object_list.rs index dbcd494..9aac7e8 100644 --- a/src/pages/object_list.rs +++ b/src/pages/object_list.rs @@ -50,6 +50,15 @@ pub struct ObjectListPage { tx: Sender, } +#[derive(Debug)] +pub(crate) struct ObjectListViewState { + filter: String, + sort: ObjectListSortType, + selected_item_key: Option, + selected_index: usize, + selected_row: usize, +} + #[derive(Debug)] enum ViewState { Default, @@ -239,6 +248,8 @@ impl ObjectListPage { } pub fn render(&mut self, f: &mut Frame, area: Rect) { + self.list_state.prepare_for_render(area.height as usize); + let offset = self.list_state.offset; let selected = self.list_state.selected; @@ -713,6 +724,51 @@ impl ObjectListPage { .send(AppEventType::ObjectListOpenManagementConsole(object_key)); } + pub(crate) fn view_state_snapshot(&self) -> ObjectListViewState { + let (selected_item_key, selected_index, selected_row) = if self.non_empty() { + ( + Some(self.current_selected_item().key().to_string()), + self.list_state.selected, + self.list_state + .selected + .saturating_sub(self.list_state.offset), + ) + } else { + (None, 0, 0) + }; + + ObjectListViewState { + filter: self.filter_input_state.input().to_string(), + sort: self.sort_dialog_state.selected(), + selected_item_key, + selected_index, + selected_row, + } + } + + pub(crate) fn restore_view_state(&mut self, state: ObjectListViewState) { + self.sort_dialog_state.set_selected(state.sort); + self.filter_input_state = InputDialogState::new(state.filter); + self.filter_view_indices(); + + if self.view_indices.is_empty() { + return; + } + + let selected = state + .selected_item_key + .as_ref() + .and_then(|selected_item_key| { + self.view_indices + .iter() + .position(|&i| self.object_items[i].key() == selected_item_key) + }) + .unwrap_or_else(|| state.selected_index.min(self.view_indices.len() - 1)); + let offset = selected.saturating_sub(state.selected_row); + + self.list_state.set_position(selected, offset); + } + pub fn current_selected_item(&self) -> &ObjectItem { let i = self .view_indices @@ -776,7 +832,7 @@ fn build_list_items<'a>( env: &Environment, theme: &Theme, ) -> Vec> { - let show_item_count = (area.height as usize) - 2 /* border */; + let show_item_count = (area.height as usize).saturating_sub(2 /* border */); view_indices .iter() .map(|&original_idx| ¤t_items[original_idx]) @@ -1211,6 +1267,132 @@ mod tests { assert_eq!(page.view_indices, vec![3, 1, 4, 0, 2]); } + #[tokio::test] + async fn test_restore_view_state_preserves_selected_object_and_scroll_position( + ) -> Result<(), core::convert::Infallible> { + let ctx = Rc::default(); + let tx = sender(); + let mut terminal = setup_terminal()?; + let area = Rect::new(0, 0, 60, 10); + let object_key = ObjectKey::bucket("test-bucket"); + + let items = (1..=12) + .map(|i| object_file_item(&format!("file{i}"), 1024, "2024-01-02 13:01:02")) + .collect(); + let mut page = ObjectListPage::new(items, object_key.clone(), Rc::clone(&ctx), tx.clone()); + terminal.draw(|f| page.render(f, area))?; + page.list_state.set_position(5, 2); + + let state = page.view_state_snapshot(); + + let items = vec![ + object_file_item("file1", 1024, "2024-01-02 13:01:02"), + object_file_item("file2", 1024, "2024-01-02 13:01:02"), + object_file_item("file3", 1024, "2024-01-02 13:01:02"), + object_file_item("file4", 1024, "2024-01-02 13:01:02"), + object_file_item("file5", 1024, "2024-01-02 13:01:02"), + object_file_item("file5.5", 1024, "2024-01-02 13:01:02"), + object_file_item("file6", 1024, "2024-01-02 13:01:02"), + object_file_item("file7", 1024, "2024-01-02 13:01:02"), + object_file_item("file8", 1024, "2024-01-02 13:01:02"), + object_file_item("file9", 1024, "2024-01-02 13:01:02"), + object_file_item("file10", 1024, "2024-01-02 13:01:02"), + object_file_item("file11", 1024, "2024-01-02 13:01:02"), + object_file_item("file12", 1024, "2024-01-02 13:01:02"), + ]; + let mut restored = ObjectListPage::new(items, object_key, ctx, tx); + restored.restore_view_state(state); + terminal.draw(|f| restored.render(f, area))?; + + assert_eq!(restored.current_selected_item().name(), "file6"); + assert_eq!(restored.list_state.selected, 6); + assert_eq!(restored.list_state.offset, 3); + + Ok(()) + } + + #[tokio::test] + async fn test_restore_view_state_falls_back_to_previous_index_when_object_was_deleted( + ) -> Result<(), core::convert::Infallible> { + let ctx = Rc::default(); + let tx = sender(); + let mut terminal = setup_terminal()?; + let area = Rect::new(0, 0, 60, 10); + let object_key = ObjectKey::bucket("test-bucket"); + + let items = (1..=16) + .map(|i| object_file_item(&format!("file{i}"), 1024, "2024-01-02 13:01:02")) + .collect(); + let mut page = ObjectListPage::new(items, object_key.clone(), Rc::clone(&ctx), tx.clone()); + terminal.draw(|f| page.render(f, area))?; + page.list_state.set_position(8, 5); + + let state = page.view_state_snapshot(); + + let items = (1..=16) + .filter(|&i| i != 9) + .map(|i| object_file_item(&format!("file{i}"), 1024, "2024-01-02 13:01:02")) + .collect(); + let mut restored = ObjectListPage::new(items, object_key, ctx, tx); + restored.restore_view_state(state); + terminal.draw(|f| restored.render(f, area))?; + + assert_eq!(restored.current_selected_item().name(), "file10"); + assert_eq!(restored.list_state.selected, 8); + assert_eq!(restored.list_state.offset, 5); + + Ok(()) + } + + #[tokio::test] + async fn test_render_after_restore_clamps_offset_before_building_items( + ) -> Result<(), core::convert::Infallible> { + let ctx = Rc::default(); + let tx = sender(); + let mut terminal = setup_terminal()?; + let area = Rect::new(0, 0, 60, 10); + let object_key = ObjectKey::bucket("test-bucket"); + + let items = (1..=16) + .map(|i| object_file_item(&format!("file{i}"), 1024, "2024-01-02 13:01:02")) + .collect(); + let mut page = ObjectListPage::new(items, object_key.clone(), Rc::clone(&ctx), tx.clone()); + terminal.draw(|f| page.render(f, area))?; + page.list_state.set_position(8, 6); + + let state = page.view_state_snapshot(); + + let items = ["file1", "file2", "file3", "file9"] + .into_iter() + .map(|name| object_file_item(name, 1024, "2024-01-02 13:01:02")) + .collect(); + let mut restored = ObjectListPage::new(items, object_key, ctx, tx); + restored.restore_view_state(state); + + terminal.draw(|f| restored.render(f, area))?; + + #[rustfmt::skip] + let mut expected = Buffer::with_lines([ + "┌─────────────────────────────────────────────────── 4 / 4 ┐", + "│ file1 2024-01-02 13:01:02 1 KiB │", + "│ file2 2024-01-02 13:01:02 1 KiB │", + "│ file3 2024-01-02 13:01:02 1 KiB │", + "│ file9 2024-01-02 13:01:02 1 KiB │", + "│ │", + "│ │", + "│ │", + "│ │", + "└──────────────────────────────────────────────────────────┘", + ]); + set_cells! { expected => + (2..58, [4]) => bg: Color::Cyan, fg: Color::Black, + } + + terminal.backend().assert_buffer(&expected); + + Ok(()) + } + fn setup_terminal() -> Result, core::convert::Infallible> { let backend = TestBackend::new(60, 10); let mut terminal = Terminal::new(backend)?; @@ -1233,7 +1415,7 @@ mod tests { fn object_dir_item(name: &str) -> ObjectItem { ObjectItem::Dir { name: name.to_string(), - key: "".to_string(), + key: format!("{name}/"), s3_uri: "".to_string(), object_url: "".to_string(), } @@ -1244,7 +1426,7 @@ mod tests { name: name.to_string(), size_byte, last_modified: parse_datetime(last_modified), - key: "".to_string(), + key: name.to_string(), s3_uri: "".to_string(), arn: "".to_string(), object_url: "".to_string(), diff --git a/src/widget/scroll_list.rs b/src/widget/scroll_list.rs index 65f8d7a..c63de29 100644 --- a/src/widget/scroll_list.rs +++ b/src/widget/scroll_list.rs @@ -25,6 +25,17 @@ impl ScrollListState { } } + pub fn set_position(&mut self, selected: usize, offset: usize) { + self.selected = selected; + self.offset = offset; + self.normalize(); + } + + pub fn prepare_for_render(&mut self, area_height: usize) { + self.height = area_height.saturating_sub(2 /* border */); + self.normalize(); + } + pub fn select_next(&mut self) { if self.total == 0 { return; @@ -110,6 +121,34 @@ impl ScrollListState { self.offset = self.total - self.height; } } + + fn normalize(&mut self) { + if self.total == 0 { + self.selected = 0; + self.offset = 0; + return; + } + + self.selected = self.selected.min(self.total - 1); + self.offset = self.offset.min(self.selected); + + if self.height == 0 { + return; + } + + let max_offset = self.total.saturating_sub(self.height); + self.offset = self.offset.min(max_offset); + + if self.selected < self.offset { + self.offset = self.selected; + return; + } + + let max_selected = self.offset + self.height - 1; + if self.selected > max_selected { + self.offset = self.selected + 1 - self.height; + } + } } #[derive(Debug, Default)] @@ -151,7 +190,7 @@ impl StatefulWidget for ScrollList<'_> { type State = ScrollListState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - state.height = area.height as usize - 2 /* border */; + state.prepare_for_render(area.height as usize); let title = format_list_count(state.total, state.selected); let list = List::new(self.items).block( diff --git a/src/widget/sort_list_dialog.rs b/src/widget/sort_list_dialog.rs index 3fb2707..4900799 100644 --- a/src/widget/sort_list_dialog.rs +++ b/src/widget/sort_list_dialog.rs @@ -66,6 +66,10 @@ impl BucketListSortDialogState { pub fn selected(&self) -> BucketListSortType { self.selected } + + pub(crate) fn set_selected(&mut self, selected: BucketListSortType) { + self.selected = selected; + } } pub struct BucketListSortDialog { @@ -168,6 +172,10 @@ impl ObjectListSortDialogState { pub fn selected(&self) -> ObjectListSortType { self.selected } + + pub(crate) fn set_selected(&mut self, selected: ObjectListSortType) { + self.selected = selected; + } } pub struct ObjectListSortDialog {