use crate::app::Message;
use crate::theme;
use bombadil_core::envspec::redact;
const MAX_LINES: usize = 500;
#[derive(Debug)]
pub struct Drawer {
pub lines: Vec<String>,
pub expanded: bool,
pub status: String,
}
impl Default for Drawer {
fn default() -> Self {
Self {
lines: Vec::new(),
expanded: true,
status: "idle".to_string(),
}
}
}
pub fn push_line(drawer: &mut Drawer, line: &str, secrets: &[String]) {
drawer.lines.push(redact(line.to_string(), secrets));
if drawer.lines.len() > MAX_LINES {
let overflow = drawer.lines.len() - MAX_LINES;
drawer.lines.drain(0..overflow);
}
}
pub fn set_idle(drawer: &mut Drawer, status: impl Into<String>) {
drawer.status = status.into();
}
fn is_failure(status: &str) -> bool {
!status.ends_with("...") && status.contains("failed")
}
pub fn view<'a>(drawer: &Drawer) -> iced::Element<'a, Message> {
let failed = is_failure(&drawer.status);
let status_colour = if failed { theme::BOOT } else { theme::SLATE };
let toggle = iced::widget::button(
iced::widget::row![
iced::widget::text(if drawer.expanded {
"\u{25be}"
} else {
"\u{25b8}"
})
.size(theme::BODY),
iced::widget::text(drawer.status.clone())
.size(theme::BODY)
.color(status_colour),
]
.spacing(theme::SPACE_1)
.align_y(iced::Alignment::Center),
)
.on_press(Message::DrawerToggled)
.padding(0.0)
.style(theme::button_bare(status_colour));
let count: iced::Element<'a, Message> = if drawer.lines.is_empty() {
iced::widget::Space::new().into()
} else {
iced::widget::text(format!("{} lines", drawer.lines.len()))
.size(theme::LABEL)
.color(theme::SLATE)
.into()
};
let header = iced::widget::row![
toggle,
iced::widget::Space::new().width(iced::Length::Fill),
count,
]
.spacing(theme::SPACE_2)
.align_y(iced::Alignment::Center);
let mut content = iced::widget::column![header].spacing(theme::SPACE_1);
if drawer.expanded {
let mut list = iced::widget::column![].spacing(theme::SPACE_1);
for line in &drawer.lines {
list = list.push(
iced::widget::text(line.clone())
.font(theme::FONT_DATA)
.size(theme::DATA),
);
}
content = content.push(
iced::widget::scrollable(list)
.height(theme::DRAWER_MAX_HEIGHT)
.anchor_bottom(),
);
}
iced::widget::container(content)
.width(iced::Length::Fill)
.padding([theme::SPACE_2, theme::SPACE_3])
.style(theme::surface)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_accumulate_in_order() {
let mut drawer = Drawer::default();
push_line(&mut drawer, "first", &[]);
push_line(&mut drawer, "second", &[]);
push_line(&mut drawer, "third", &[]);
assert_eq!(drawer.lines, vec!["first", "second", "third"]);
}
#[test]
fn the_buffer_is_bounded_and_keeps_the_most_recent_lines() {
let mut drawer = Drawer::default();
let pushed = MAX_LINES + 10;
for i in 0..pushed {
push_line(&mut drawer, &format!("line-{i}"), &[]);
}
assert_eq!(drawer.lines.len(), MAX_LINES, "got {}", drawer.lines.len());
assert_eq!(
drawer.lines.first().map(String::as_str),
Some("line-10"),
"the oldest 10 lines must have been dropped; got {:?}",
drawer.lines.first()
);
assert_eq!(
drawer.lines.last().map(String::as_str),
Some(format!("line-{}", pushed - 1)).as_deref(),
"the most recently pushed line must survive; got {:?}",
drawer.lines.last()
);
}
#[test]
fn finishing_a_command_keeps_its_transcript_on_screen() {
let mut drawer = Drawer::default();
push_line(&mut drawer, "resolving dependencies...", &[]);
set_idle(&mut drawer, "sync succeeded");
assert!(
drawer.expanded,
"a finished command must leave its output where the user can read it"
);
assert_eq!(drawer.status, "sync succeeded");
assert_eq!(drawer.lines.len(), 1, "and the transcript must survive");
}
#[test]
fn output_arriving_does_not_reopen_a_drawer_the_user_closed() {
let mut drawer = Drawer {
expanded: false,
..Drawer::default()
};
push_line(&mut drawer, "resolving dependencies...", &[]);
set_idle(&mut drawer, "sync succeeded");
assert!(!drawer.expanded, "only the user opens it");
assert_eq!(
drawer.lines.len(),
1,
"the transcript is still collected while closed, ready to show"
);
}
#[test]
fn a_new_drawer_starts_open() {
assert!(Drawer::default().expanded);
}
#[test]
fn a_secret_never_reaches_the_drawer() {
let mut drawer = Drawer::default();
let secrets = vec!["s3cr3t-value".to_string()];
push_line(
&mut drawer,
"+ curl -H 'Authorization: Bearer s3cr3t-value'",
&secrets,
);
assert!(
drawer.lines.iter().all(|l| !l.contains("s3cr3t-value")),
"a secret leaked into the stored lines: {:?}",
drawer.lines
);
assert!(drawer.lines[0].contains("***"), "got {:?}", drawer.lines[0]);
}
#[test]
fn every_failure_status_app_update_sets_is_recognised() {
for status in [
"sync failed",
"add project failed",
"open terminal failed",
"open folder failed",
"remove failed to persist",
"env var change failed to persist",
"script change failed to persist",
"index change failed to persist",
"member selection failed to persist",
"settings change failed to persist",
] {
assert!(is_failure(status), "{status:?} must read as a failure");
}
}
#[test]
fn a_success_or_in_progress_status_is_not_a_failure() {
for status in [
"idle",
"sync succeeded",
"project added",
"syncing api...",
"recreating api...",
] {
assert!(!is_failure(status), "{status:?} must not read as a failure");
}
}
#[test]
fn a_project_label_containing_failed_does_not_paint_a_healthy_sync_boot() {
for status in [
"syncing failed-migrations...",
"recreating failed-migrations...",
"syncing members for failed-migrations...",
"syncing failed...",
] {
assert!(
!is_failure(status),
"{status:?} is a sync in flight, not a failure"
);
}
}
}