diff --git a/docs/src/configurations/config-file-format.md b/docs/src/configurations/config-file-format.md index 871ef83..bab895f 100644 --- a/docs/src/configurations/config-file-format.md +++ b/docs/src/configurations/config-file-format.md @@ -9,6 +9,9 @@ max_concurrent_requests = 5 default_region = "us-east-1" +[ui.header] +show_region = true + [ui.bucket_list] default_sort = "default" @@ -83,6 +86,13 @@ The default region to use if the region cannot be obtained from the command line - type: `string` - default: `us-east-1` +### `ui.header.show_region` + +Whether to display the AWS region resolved by the client (e.g. from the `--region` flag, `AWS_REGION` env var, or AWS profile) in the top-right of the header bar. The label is hidden automatically when the resolved region is just the configured `default_region` fallback. + +- type: `bool` +- default: `true` + ### `ui.bucket_list.default_sort` The default sort order of the bucket list. diff --git a/src/app.rs b/src/app.rs index a66412a..e7f19ed 100644 --- a/src/app.rs +++ b/src/app.rs @@ -72,11 +72,24 @@ pub struct App { notification: Notification, is_loading: bool, pending_single_bucket_reload: Option, + header_region: Option, } impl App { pub fn new(mapper: UserEventMapper, client: Client, ctx: AppContext, tx: Sender) -> App { let ctx = Rc::new(ctx); + // Use the region the AWS SDK actually resolved (honors --region CLI arg, + // AWS_REGION / AWS_DEFAULT_REGION env vars, and AWS profile config). + // Suppress the label when the SDK fell back to the configured default, + // so users who haven't set anything explicitly don't see a misleading + // "[us-east-1]" in the header. + let resolved_region = client.region(); + let header_region = + if !ctx.config.ui.header.show_region || resolved_region == ctx.config.default_region { + None + } else { + Some(resolved_region.to_string()) + }; App { app_objects: AppObjects::default(), page_stack: PageStack::new(Rc::clone(&ctx), tx.clone()), @@ -87,6 +100,7 @@ impl App { notification: Notification::None, is_loading: true, pending_single_bucket_reload: None, + header_region, } } @@ -1023,7 +1037,9 @@ impl App { fn render_header(&self, f: &mut Frame, area: Rect) { if !area.is_empty() { - let header = Header::new(self.page_stack.breadcrumb()).theme(self.ctx.theme()); + let header = Header::new(self.page_stack.breadcrumb()) + .theme(self.ctx.theme()) + .region(self.header_region.clone()); f.render_widget(header, area); } } diff --git a/src/config.rs b/src/config.rs index 37d6d7f..b41521c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,8 @@ pub struct Config { #[optional(derives = [Deserialize])] #[derive(Debug, Clone, SmartDefault)] pub struct UiConfig { + #[nested] + pub header: UiHeaderConfig, #[nested] pub bucket_list: UiBucketListConfig, #[nested] @@ -51,6 +53,13 @@ pub struct UiConfig { pub theme: Theme, } +#[optional(derives = [Deserialize])] +#[derive(Debug, Clone, SmartDefault)] +pub struct UiHeaderConfig { + #[default = true] + pub show_region: bool, +} + #[optional(derives = [Deserialize])] #[derive(Debug, Clone, SmartDefault)] pub struct UiBucketListConfig { @@ -258,6 +267,23 @@ object_dir_bold = false assert_eq!(config.ui.theme.dialog_selected, Color::Cyan); } + #[test] + fn header_show_region_defaults_to_true_when_omitted() { + let config = parse_config(""); + assert!(config.ui.header.show_region); + } + + #[test] + fn header_show_region_can_be_disabled() { + let config = parse_config( + r#" +[ui.header] +show_region = false +"#, + ); + assert!(!config.ui.header.show_region); + } + #[test] fn invalid_theme_color_is_rejected() { let result = toml::from_str::( diff --git a/src/widget/header.rs b/src/widget/header.rs index 32c1976..76982b9 100644 --- a/src/widget/header.rs +++ b/src/widget/header.rs @@ -1,6 +1,6 @@ use ratatui::{ buffer::Buffer, - layout::{Margin, Rect}, + layout::{Alignment, Margin, Rect}, style::{Color, Stylize}, widgets::{Block, Padding, Paragraph, Widget}, }; @@ -25,6 +25,7 @@ impl HeaderColor { #[derive(Debug, Default)] pub struct Header { breadcrumb: Vec, + region: Option, color: HeaderColor, } @@ -40,6 +41,11 @@ impl Header { self.color = HeaderColor::new(theme); self } + + pub fn region(mut self, region: Option) -> Self { + self.region = region.filter(|s| !s.is_empty()); + self + } } impl Widget for Header { @@ -51,8 +57,14 @@ impl Widget for Header { impl Header { const DELIMITER: &'static str = " / "; const ELLIPSIS: &'static str = "..."; + const REGION_GAP: usize = 2; fn render_header(self, area: Rect, buf: &mut Buffer) { + if self.region.is_some() { + self.render_header_with_region(area, buf); + return; + } + let inner_area = area.inner(Margin::new(1, 1)); let pad = Padding::horizontal(1); let max_width = (inner_area.width - pad.left - pad.right) as usize; @@ -67,6 +79,54 @@ impl Header { paragraph.render(area, buf); } + fn render_header_with_region(self, area: Rect, buf: &mut Buffer) { + let pad = Padding::horizontal(1); + let block_color = self.color.block; + let text_color = self.color.text; + + let block = Block::bordered().fg(block_color).padding(pad); + let inner_area = block.inner(area); + block.render(area, buf); + + if inner_area.width == 0 || inner_area.height == 0 { + return; + } + + let total_width = inner_area.width as usize; + + // Region is guaranteed to be Some here; format and measure it. + let region_text = format!("[{}]", self.region.as_deref().unwrap()); + let region_width = console::measure_text_width(®ion_text); + + // If the region label doesn't fit, fall back to the breadcrumb-only layout. + if region_width + Self::REGION_GAP >= total_width { + let breadcrumb_str = self.build_current_key_str(total_width).fg(text_color); + Paragraph::new(breadcrumb_str).render(inner_area, buf); + return; + } + + let breadcrumb_width = total_width - region_width - Self::REGION_GAP; + + let breadcrumb_str = self.build_current_key_str(breadcrumb_width).fg(text_color); + let breadcrumb_area = Rect { + x: inner_area.x, + y: inner_area.y, + width: breadcrumb_width as u16, + height: inner_area.height, + }; + Paragraph::new(breadcrumb_str).render(breadcrumb_area, buf); + + let region_area = Rect { + x: inner_area.x + (total_width - region_width) as u16, + y: inner_area.y, + width: region_width as u16, + height: inner_area.height, + }; + Paragraph::new(region_text.fg(text_color)) + .alignment(Alignment::Right) + .render(region_area, buf); + } + fn build_current_key_str(self, max_width: usize) -> String { if self.breadcrumb.is_empty() { return "".to_string(); @@ -106,7 +166,7 @@ mod tests { .into_iter() .map(|s| s.to_string()) .collect(); - let header = Header::new(breadcrumb).theme(&theme); + let header = Header::new(breadcrumb).theme(&theme).region(None); let mut buf = Buffer::empty(Rect::new(0, 0, 30 + 4, 3)); header.render(buf.area, &mut buf); @@ -154,4 +214,26 @@ mod tests { ]); assert_eq!(buf, expected); } + + #[test] + fn test_render_header_with_region() { + let theme = Theme::default(); + let breadcrumb = ["bucket", "key01"] + .into_iter() + .map(|s| s.to_string()) + .collect(); + let header = Header::new(breadcrumb) + .theme(&theme) + .region(Some("eu-central-1".to_string())); + let mut buf = Buffer::empty(Rect::new(0, 0, 40, 3)); + header.render(buf.area, &mut buf); + + #[rustfmt::skip] + let expected = Buffer::with_lines([ + "┌──────────────────────────────────────┐", + "│ bucket / key01 [eu-central-1] │", + "└──────────────────────────────────────┘", + ]); + assert_eq!(buf, expected); + } }