Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/src/configurations/config-file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ max_concurrent_requests = 5

default_region = "us-east-1"

[ui.header]
show_region = true

[ui.bucket_list]
default_sort = "default"

Expand Down Expand Up @@ -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.
Expand Down
18 changes: 17 additions & 1 deletion src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,24 @@ pub struct App {
notification: Notification,
is_loading: bool,
pending_single_bucket_reload: Option<BucketItem>,
header_region: Option<String>,
}

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()),
Expand All @@ -87,6 +100,7 @@ impl App {
notification: Notification::None,
is_loading: true,
pending_single_bucket_reload: None,
header_region,
}
}

Expand Down Expand Up @@ -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);
}
}
Expand Down
26 changes: 26 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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 {
Expand Down Expand Up @@ -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::<OptionalConfig>(
Expand Down
86 changes: 84 additions & 2 deletions src/widget/header.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use ratatui::{
buffer::Buffer,
layout::{Margin, Rect},
layout::{Alignment, Margin, Rect},
style::{Color, Stylize},
widgets::{Block, Padding, Paragraph, Widget},
};
Expand All @@ -25,6 +25,7 @@ impl HeaderColor {
#[derive(Debug, Default)]
pub struct Header {
breadcrumb: Vec<String>,
region: Option<String>,
color: HeaderColor,
}

Expand All @@ -40,6 +41,11 @@ impl Header {
self.color = HeaderColor::new(theme);
self
}

pub fn region(mut self, region: Option<String>) -> Self {
self.region = region.filter(|s| !s.is_empty());
self
}
}

impl Widget for Header {
Expand All @@ -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;
Expand All @@ -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(&region_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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
}
Loading