diff --git a/bb-imager-gui/assets/icons/search.svg b/bb-imager-gui/assets/icons/search.svg
new file mode 100644
index 0000000..b78a3fe
--- /dev/null
+++ b/bb-imager-gui/assets/icons/search.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/bb-imager-gui/src/constants.rs b/bb-imager-gui/src/constants.rs
index c329cdb..62f0957 100644
--- a/bb-imager-gui/src/constants.rs
+++ b/bb-imager-gui/src/constants.rs
@@ -31,6 +31,7 @@ pub(crate) const ARROW_FORWARD_IOS_ICON_BYTES: &[u8] =
pub(crate) const FILE_SAVE_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/file-save.svg");
pub(crate) const INFO_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/info.svg");
pub(crate) const COPY_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/content-copy.svg");
+pub(crate) const SEARCH_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/search.svg");
// Font
pub(crate) const FONT_REGULAR: iced::Font = iced::Font::with_name("Nunito");
diff --git a/bb-imager-gui/src/db/mod.rs b/bb-imager-gui/src/db/mod.rs
index 7622d0e..01f024b 100644
--- a/bb-imager-gui/src/db/mod.rs
+++ b/bb-imager-gui/src/db/mod.rs
@@ -527,8 +527,9 @@ impl Db {
}
/// Get board list data. (ID, Icon, Name)
- pub(crate) async fn board_list(&self) -> sqlx::Result> {
- sqlx::query_as("SELECT id, icon, name FROM boards")
+ pub(crate) async fn board_list(&self, search: &str) -> sqlx::Result> {
+ sqlx::query_as("SELECT id, icon, name FROM boards WHERE name LIKE $1 COLLATE NOCASE")
+ .bind(format!("%{}%", search))
.fetch_all(&self.db)
.await
}
diff --git a/bb-imager-gui/src/db/tests.rs b/bb-imager-gui/src/db/tests.rs
index ae58486..a63bfe1 100644
--- a/bb-imager-gui/src/db/tests.rs
+++ b/bb-imager-gui/src/db/tests.rs
@@ -325,7 +325,7 @@ async fn add_config_inserts_device_into_board_list() {
db.init().await.expect("DB initialization should succeed");
let initial_boards = db
- .board_list()
+ .board_list("")
.await
.expect("Fetching board list should succeed");
@@ -357,7 +357,7 @@ async fn add_config_inserts_device_into_board_list() {
.expect("add_config should succeed");
let updated_boards = db
- .board_list()
+ .board_list("")
.await
.expect("Fetching board list should succeed");
@@ -419,7 +419,7 @@ async fn add_config_updates_existing_device_with_same_name() {
// Get inserted board id
let boards = db
- .board_list()
+ .board_list("")
.await
.expect("Fetching board list should succeed");
@@ -456,7 +456,7 @@ async fn add_config_updates_existing_device_with_same_name() {
// Ensure board count unchanged
let updated_boards = db
- .board_list()
+ .board_list("")
.await
.expect("Fetching board list should succeed");
@@ -544,7 +544,7 @@ async fn add_config_inserts_os_image_for_board() {
.await
.expect("add_config should succeed");
- let boards = db.board_list().await.unwrap();
+ let boards = db.board_list("").await.unwrap();
let board_id = boards.iter().find(|b| b.name == board.name).unwrap().id;
let items = db
@@ -623,7 +623,7 @@ async fn os_image_by_id_returns_correct_data() {
.await
.expect("add_config should succeed");
- let boards = db.board_list().await.unwrap();
+ let boards = db.board_list("").await.unwrap();
let board_id = boards.iter().find(|b| b.name == "Test Board").unwrap().id;
let items = db
@@ -730,7 +730,7 @@ async fn add_config_inserts_os_sublist_for_board() {
.await
.expect("add_config should succeed");
- let boards = db.board_list().await.unwrap();
+ let boards = db.board_list("").await.unwrap();
let board_id = boards.iter().find(|b| b.name == "Test Board").unwrap().id;
let items = db
@@ -827,7 +827,7 @@ async fn nested_os_sublists_propagate_board_support() {
.expect("add_config should succeed");
let board_id = db
- .board_list()
+ .board_list("")
.await
.unwrap()
.into_iter()
@@ -906,7 +906,7 @@ async fn remote_os_sublist_is_returned_for_board() {
.expect("add_config should succeed");
let board_id = db
- .board_list()
+ .board_list("")
.await
.unwrap()
.into_iter()
@@ -994,7 +994,7 @@ async fn remote_os_sublist_resolve_inserts_child_items_and_clears_url() {
.expect("add_config should succeed");
let board_id = db
- .board_list()
+ .board_list("")
.await
.unwrap()
.into_iter()
@@ -1102,7 +1102,7 @@ async fn duplicate_remote_sublist_resolve_does_not_duplicate_os_items() {
.expect("add_config should succeed");
let board_id = db
- .board_list()
+ .board_list("")
.await
.unwrap()
.into_iter()
@@ -1158,3 +1158,88 @@ async fn duplicate_remote_sublist_resolve_does_not_duplicate_os_items() {
assert!(remote_lists_after.is_empty(),);
}
+
+/// This test verifies that board_list(search) correctly filters boards
+/// using a case-insensitive LIKE query.
+///
+/// What this test checks:
+/// 1. Multiple boards are inserted into the database.
+/// 2. board_list(Some("beagle")) is called.
+/// 3. Only boards whose names contain "beagle" (case-insensitive) are returned.
+/// 4. Non-matching boards are excluded.
+///
+/// Why this test is needed:
+/// - board_list() has two execution paths (search and non-search).
+/// - Ensures the LIKE query is applied correctly.
+/// - Ensures COLLATE NOCASE works as expected.
+/// - Prevents regressions where search returns all boards or none.
+///
+/// Without this test:
+/// - Search filtering could silently break.
+/// - Case-insensitive matching might stop working.
+/// - UI board search would behave incorrectly.
+#[tokio::test]
+async fn board_list_search_filters_boards_case_insensitive() {
+ let db = Db::new().expect("Failed to create DB");
+ db.init().await.expect("DB init should succeed");
+
+ let board1 = bb_config::config::Device {
+ name: "Test Board 1".to_string(),
+ description: "Board 1".to_string(),
+ icon: None,
+ flasher: bb_config::config::Flasher::SdCard,
+ instructions: None,
+ oshw: None,
+ specification: vec![],
+ documentation: None,
+ tags: HashSet::from(["bbb".to_string()]),
+ };
+
+ let board2 = bb_config::config::Device {
+ name: "Test Board 2".to_string(),
+ description: "Board 2".to_string(),
+ icon: None,
+ flasher: bb_config::config::Flasher::SdCard,
+ instructions: None,
+ oshw: None,
+ specification: vec![],
+ documentation: None,
+ tags: HashSet::from(["beagleplay".to_string()]),
+ };
+
+ let board3 = bb_config::config::Device {
+ name: "Test Board 3".to_string(),
+ description: "Board 3".to_string(),
+ icon: None,
+ flasher: bb_config::config::Flasher::SdCard,
+ instructions: None,
+ oshw: None,
+ specification: vec![],
+ documentation: None,
+ tags: HashSet::from(["rpi".to_string()]),
+ };
+
+ let config = Config {
+ imager: bb_config::config::Imager {
+ remote_configs: Default::default(),
+ devices: vec![board1, board2, board3],
+ },
+ os_list: vec![],
+ };
+
+ db.add_config(config)
+ .await
+ .expect("add_config should succeed");
+
+ let results = db.board_list("test").await.expect("search should succeed");
+
+ assert_eq!(
+ results.len(),
+ 3,
+ "Only boards containing 'test' should be returned"
+ );
+
+ assert!(results.iter().any(|b| b.name == "Test Board 1"));
+ assert!(results.iter().any(|b| b.name == "Test Board 2"));
+ assert!(results.iter().any(|b| b.name == "Test Board 3"));
+}
diff --git a/bb-imager-gui/src/main.rs b/bb-imager-gui/src/main.rs
index 7cac8f2..5e56ea1 100644
--- a/bb-imager-gui/src/main.rs
+++ b/bb-imager-gui/src/main.rs
@@ -98,6 +98,7 @@ impl BBImager {
common,
boards: Vec::new(),
selected_board: None,
+ search_text: String::new(),
})
}
@@ -163,10 +164,6 @@ impl BBImager {
)
}
- fn fetch_board_images(&self) -> Task {
- self.common().fetch_board_images()
- }
-
fn common_mut(&mut self) -> &mut BBImagerCommon {
match self {
BBImager::ChooseBoard(x) => &mut x.common,
@@ -222,7 +219,11 @@ impl BBImager {
}
};
- self.refresh_board_list()
+ if let BBImager::ChooseBoard(x) = self {
+ return x.refresh_board_list();
+ }
+
+ Task::none()
}
fn subscription(&self) -> Subscription {
@@ -315,15 +316,6 @@ impl BBImager {
t
}
- fn refresh_board_list(&self) -> Task {
- let db = self.common().db.clone();
-
- Task::perform(
- async move { db.board_list().await.unwrap() },
- BBImagerMessage::UpdateBoardList,
- )
- }
-
fn resolve_remote_sublists(&self, board_id: i64, pos: Option) -> Task {
let db = self.common().db.clone();
let downloader = self.common().downloader.clone();
@@ -415,8 +407,8 @@ impl BBImager {
};
match self {
- BBImager::ChooseBoard(_) => {
- Task::batch([self.refresh_board_list(), self.scroll_reset()])
+ BBImager::ChooseBoard(inner) => {
+ Task::batch([inner.refresh_board_list(), self.scroll_reset()])
}
BBImager::ChooseOs(inner) => {
let board_id = inner.selected_board.id;
diff --git a/bb-imager-gui/src/message.rs b/bb-imager-gui/src/message.rs
index e71c33c..1ee3e9e 100644
--- a/bb-imager-gui/src/message.rs
+++ b/bb-imager-gui/src/message.rs
@@ -83,6 +83,9 @@ pub(crate) enum BBImagerMessage {
/// DB Ops
DbInitSuccess,
+
+ /// Search
+ UpdateSearchText(String),
}
pub(crate) fn update(state: &mut BBImager, message: BBImagerMessage) -> Task {
@@ -233,11 +236,14 @@ pub(crate) fn update(state: &mut BBImager, message: BBImagerMessage) -> Task Task match state {
+ BBImager::ChooseBoard(inner) => {
+ return inner.update_search(x);
+ }
+ _ => {}
+ },
BBImagerMessage::Null => {}
}
diff --git a/bb-imager-gui/src/state.rs b/bb-imager-gui/src/state.rs
index 0bb315d..245194b 100644
--- a/bb-imager-gui/src/state.rs
+++ b/bb-imager-gui/src/state.rs
@@ -60,6 +60,7 @@ pub(crate) struct ChooseBoardState {
pub(crate) common: BBImagerCommon,
pub(crate) boards: Vec,
pub(crate) selected_board: Option,
+ pub(crate) search_text: String,
}
impl ChooseBoardState {
@@ -70,6 +71,21 @@ impl ChooseBoardState {
pub(crate) fn image_handle_cache(&self) -> &helpers::ImageHandleCache {
&self.common.img_handle_cache
}
+
+ pub(crate) fn refresh_board_list(&self) -> Task {
+ let db = self.common.db.clone();
+ let search = self.search_text.clone();
+
+ Task::perform(
+ async move { db.board_list(&search).await.unwrap() },
+ BBImagerMessage::UpdateBoardList,
+ )
+ }
+
+ pub(crate) fn update_search(&mut self, search: String) -> Task {
+ self.search_text = search;
+ self.refresh_board_list()
+ }
}
impl From for ChooseBoardState {
@@ -78,6 +94,7 @@ impl From for ChooseBoardState {
common: value.common,
boards: Vec::new(),
selected_board: Some(value.selected_board),
+ search_text: String::new(),
}
}
}
diff --git a/bb-imager-gui/src/ui/board_selection.rs b/bb-imager-gui/src/ui/board_selection.rs
index 8808f95..16c2786 100644
--- a/bb-imager-gui/src/ui/board_selection.rs
+++ b/bb-imager-gui/src/ui/board_selection.rs
@@ -60,9 +60,24 @@ fn board_list_pane<'a>(state: &'a ChooseBoardState) -> Element<'a, BBImagerMessa
})
.map(Into::into);
- widget::scrollable(column(items).padding(LIST_COL_PADDING))
- .id(state.common.scroll_id.clone())
- .into()
+ widget::scrollable(
+ column(
+ [
+ helpers::search_box(&state.search_text).into(),
+ widget::center(widget::rule::horizontal(2))
+ .padding(iced::Padding {
+ left: 16.0,
+ ..Default::default()
+ })
+ .into(),
+ ]
+ .into_iter()
+ .chain(items),
+ )
+ .padding(LIST_COL_PADDING),
+ )
+ .id(state.common.scroll_id.clone())
+ .into()
}
fn board_view_pane<'a>(state: &'a ChooseBoardState) -> Element<'a, BBImagerMessage> {
diff --git a/bb-imager-gui/src/ui/helpers.rs b/bb-imager-gui/src/ui/helpers.rs
index 299297a..e204f43 100644
--- a/bb-imager-gui/src/ui/helpers.rs
+++ b/bb-imager-gui/src/ui/helpers.rs
@@ -32,6 +32,8 @@ pub(crate) static INFO_ICON: LazyLock =
LazyLock::new(|| svg::Handle::from_memory(constants::INFO_ICON_BYTES));
pub(crate) static COPY_ICON: LazyLock =
LazyLock::new(|| svg::Handle::from_memory(constants::COPY_ICON_BYTES));
+pub(crate) static SEARCH_ICON: LazyLock =
+ LazyLock::new(|| svg::Handle::from_memory(constants::SEARCH_ICON_BYTES));
pub(crate) const VIEW_COL_PADDING: u16 = 16;
pub(crate) const LIST_COL_PADDING: iced::Padding = iced::Padding {
@@ -443,3 +445,29 @@ pub(crate) fn copy_btn<'a>(handle: svg::Handle) -> widget::Button<'a, BBImagerMe
.width(iced::Shrink)
.style(widget::button::secondary)
}
+
+pub(crate) fn search_box<'a>(inp: &'a str) -> widget::Container<'a, BBImagerMessage> {
+ widget::container(
+ widget::row![
+ widget::svg(SEARCH_ICON.clone())
+ .style(svg_icon_style)
+ .width(iced::Length::Shrink)
+ .height(18),
+ widget::text_input("SEARCH", inp)
+ .style(|theme, status| {
+ let mut temp = widget::text_input::default(theme, status);
+ temp.border.width = 0.0;
+ temp.background = iced::Background::Color(iced::Color::TRANSPARENT);
+ temp
+ })
+ .on_input(BBImagerMessage::UpdateSearchText),
+ ]
+ .align_y(iced::Alignment::Center),
+ )
+ .padding(iced::Padding {
+ left: 16.0,
+ top: 16.0,
+ bottom: 8.0,
+ ..Default::default()
+ })
+}