diff --git a/apps/bootable-desktop/src/main.rs b/apps/bootable-desktop/src/main.rs index 2dfd1e7..8fd04fb 100644 --- a/apps/bootable-desktop/src/main.rs +++ b/apps/bootable-desktop/src/main.rs @@ -9,8 +9,9 @@ use bootable_core::{ DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, ImageKind, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, PiImage, Progress, QuickAccess, ReviewReadiness, ReviewedWriteSession, WindowsPartitionScheme, - WorkspaceStepState, WriteCompletion, WriteOptions, format_bytes, removable_media_status, - review_readiness, target_eligibility_label, workspace_progress, + WorkspaceStepState, WriteCompletion, WriteOptions, catalog_search_summary, + distribution_matches_query, format_bytes, removable_media_status, review_readiness, + target_eligibility_label, workspace_progress, }; use futures::{ AsyncReadExt, FutureExt, StreamExt, @@ -298,7 +299,7 @@ impl BootableView { let engine = Bootable::native(); let catalog_search = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Search distributions and images…") + .placeholder("Search by name, slug, or base family…") .clean_on_escape() }); let search_subscription = cx.subscribe(&catalog_search, |view, input, event, cx| { @@ -488,10 +489,12 @@ impl BootableView { self.load_distribution_directory(cx); } else { self.distributions = self.distribution_directory.clone(); - self.status = format!( - "Searching {} distributions from DistroWatch", - self.distribution_directory.len() - ); + let matches = self + .distributions + .iter() + .filter(|distribution| distribution_matches_query(distribution, query)) + .count(); + self.status = catalog_search_summary(query, matches); cx.notify(); } } @@ -517,13 +520,20 @@ impl BootableView { fetch.value.is_empty(), ); let directory = fetch.value; - let count = directory.len(); view.distribution_directory = directory.clone(); if !view.catalog_search_query(cx).is_empty() && view.discovery_session.source() == DiscoverySource::DistroWatch { view.distributions = directory; - view.status = format!("Searching {count} distributions"); + let query = view.catalog_search_query(cx); + let matches = view + .distributions + .iter() + .filter(|distribution| { + distribution_matches_query(distribution, &query) + }) + .count(); + view.status = catalog_search_summary(&query, matches); } } Err(error) => { @@ -2128,15 +2138,7 @@ impl BootableView { .distributions .iter() .enumerate() - .filter(|(_, distribution)| { - query.is_empty() - || distribution.name.to_lowercase().contains(&query) - || distribution.slug.to_lowercase().contains(&query) - || distribution - .based_on - .as_deref() - .is_some_and(|value| value.to_lowercase().contains(&query)) - }) + .filter(|(_, distribution)| distribution_matches_query(distribution, &query)) .take(self.catalog_visible) .map(|(index, distribution)| { let selected = self.selected_distribution == Some(index); @@ -2199,13 +2201,26 @@ impl BootableView { )), ), ) - .child(div().text_xs().text_color(rgb(0x8fa4bd)).child( - if distribution.rank == 0 { - "Directory".into() - } else { - format!("{} / day", distribution.hits_per_day) - }, - )) + .child( + div() + .flex() + .flex_col() + .items_end() + .child(div().text_xs().text_color(rgb(0x8fa4bd)).child( + if distribution.rank == 0 { + "Directory".into() + } else { + format!("{} / day", distribution.hits_per_day) + }, + )) + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(if selected { 0x5bd7c0 } else { 0x8fc7ff })) + .child(if selected { "Selected" } else { "Select →" }), + ), + ) }) .collect::>(); let releases = self @@ -2289,7 +2304,7 @@ impl BootableView { distribution_state, CatalogState::Ready { .. } | CatalogState::Empty ) { - "No matching distributions".into() + catalog_search_summary(&query, 0) } else { distribution_state.short_label("distributions") }; @@ -2304,6 +2319,26 @@ impl BootableView { .state(CatalogFacet::Details) .short_label("ISO releases") }; + let distribution_heading = if !query.is_empty() { + "SEARCH RESULTS" + } else { + match self.discovery_session.quick_access() { + QuickAccess::Arch => "ARCH-BASED", + QuickAccess::Debian => "DEBIAN-BASED", + QuickAccess::Omarchy => "OMARCHY", + _ => "POPULAR · SIX MONTHS", + } + }; + let refresh_label = if distribution_state.is_failed() + || self + .discovery_session + .state(CatalogFacet::Details) + .is_failed() + { + "Retry" + } else { + "Refresh" + }; div() .flex() @@ -2349,7 +2384,8 @@ impl BootableView { Button::new("reload-catalog") .compact() .icon(Icon::empty().path("ui/refresh.svg")) - .tooltip("Reload DistroWatch data") + .label(refresh_label) + .tooltip("Refresh DistroWatch data") .on_click(cx.listener(|this, _, _, cx| { this.retry_discovery(cx); })), @@ -2376,7 +2412,7 @@ impl BootableView { .text_xs() .font_weight(FontWeight::SEMIBOLD) .text_color(rgb(0x8fa4bd)) - .child("POPULAR · SIX MONTHS"), + .child(distribution_heading), ) .child( div() @@ -3121,8 +3157,10 @@ impl BootableView { .discovery_session .state(CatalogFacet::RaspberryPi) .short_label("Raspberry Pi images"); - let image_message = if self.pi_catalog.is_some() { - "No compatible image found".into() + let image_message = if self.pi_catalog.is_some() && !query.is_empty() { + format!("No Raspberry Pi images match “{query}”") + } else if self.pi_catalog.is_some() { + "No compatible Raspberry Pi images found".into() } else { pi_message.clone() }; @@ -3159,7 +3197,18 @@ impl BootableView { Button::new("reload-pi-catalog") .compact() .icon(Icon::empty().path("ui/refresh.svg")) - .tooltip("Reload Raspberry Pi catalog") + .label( + if self + .discovery_session + .state(CatalogFacet::RaspberryPi) + .is_failed() + { + "Retry" + } else { + "Refresh" + }, + ) + .tooltip("Refresh Raspberry Pi catalog") .on_click(cx.listener(|this, _, _, cx| { this.show_raspberry_pi_with(CacheMode::Refresh, cx); })), @@ -3706,6 +3755,18 @@ impl BootableView { } fn target_cards(&self, cx: &mut Context) -> impl IntoElement { + if self.devices.is_empty() { + return div() + .p_4() + .rounded_lg() + .border_1() + .border_color(rgb(0x1f2c3c)) + .bg(rgb(0x0d151f)) + .text_sm() + .text_color(rgb(0x8fa4bd)) + .child("Connect a removable USB or SD drive, then refresh") + .into_any_element(); + } let cards = self .devices .iter() @@ -3755,15 +3816,40 @@ impl BootableView { ) .child( div() - .text_sm() - .font_weight(FontWeight::SEMIBOLD) - .text_color(rgb(0x9ec9c1)) - .child(format_bytes(device.capacity)), + .flex() + .flex_col() + .items_end() + .gap_1() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x9ec9c1)) + .child(format_bytes(device.capacity)), + ) + .child( + div() + .text_xs() + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(if blocked { 0xf29a9a } else { 0x8fc7ff })) + .child(if blocked { + "Blocked" + } else if selected { + "Selected" + } else { + "Select →" + }), + ), ) }) .collect::>(); - div().flex().flex_col().gap_3().children(cards) + div() + .flex() + .flex_col() + .gap_3() + .children(cards) + .into_any_element() } fn download_history_card(&self, cx: &mut Context) -> impl IntoElement { @@ -3993,7 +4079,7 @@ impl BootableView { .text_sm() .font_weight(FontWeight::BOLD) .text_color(rgb(0xe5b95f)) - .child("α"), + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), ), ) .when(!compact, |brand| { @@ -4167,7 +4253,7 @@ impl BootableView { div() .text_sm() .text_color(rgb(0x8fa4bd)) - .child("All · Arch · Debian · Omarchy · Windows · Raspberry Pi"), + .child("Browse trusted catalogs · Open →"), ) } @@ -4219,7 +4305,8 @@ impl BootableView { .child( div() .text_xs() - .text_color(rgb(0x6f8299)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(0x5bd7c0)) .child(removable_media_status(&self.devices)), ), ) @@ -4405,7 +4492,7 @@ impl Render for BootableView { .text_xs() .font_weight(FontWeight::BOLD) .text_color(rgb(0xe5b95f)) - .child("α"), + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), ), ), ) @@ -4617,6 +4704,7 @@ fn main() { titlebar: Some(titlebar), app_id: Some("app.bootable.Bootable".into()), window_decorations: Some(WindowDecorations::Client), + window_background: WindowBackgroundAppearance::Opaque, ..WindowOptions::default() }; cx.open_window(options, |window, cx| { diff --git a/apps/bootable-tui/src/main.rs b/apps/bootable-tui/src/main.rs index 78e13a9..adfbcaf 100644 --- a/apps/bootable-tui/src/main.rs +++ b/apps/bootable-tui/src/main.rs @@ -10,8 +10,9 @@ use bootable_core::{ DistributionDetails, DistributionSummary, DownloadCompletion, DownloadLaunch, DownloadRequest, DownloadStatus, ImageReport, IsoRelease, ManagedDownloadSession, OperationState, PiCatalog, Progress, ProgressPhase, QuickAccess, ReviewReadiness, ReviewedWriteSession, - WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, format_bytes, - removable_media_status, review_readiness, target_eligibility_label, workspace_progress, + WorkspaceStepState, WriteCompletion, WriteOptions, WritePlan, catalog_search_summary, + distribution_matches_query, format_bytes, removable_media_status, review_readiness, + target_eligibility_label, workspace_progress, }; use clap::{Args, Parser, Subcommand}; use crossterm::event::{ @@ -1162,7 +1163,10 @@ impl App { if !self.catalog_query.is_empty() { self.distributions = directory; self.catalog_selected = 0; - self.status = "Search catalog ready".into(); + self.status = catalog_search_summary( + &self.catalog_query, + self.filtered_distribution_indices().len(), + ); } } Err(error) => { @@ -1607,7 +1611,14 @@ impl App { match code { KeyCode::Esc | KeyCode::Enter => { self.catalog_searching = false; - self.status = "Search applied • scroll for more matching results".into(); + self.status = if self.catalog_query.is_empty() { + "Search closed · showing DistroWatch six-month popularity".into() + } else { + catalog_search_summary( + &self.catalog_query, + self.filtered_distribution_indices().len(), + ) + }; } KeyCode::Backspace => { self.catalog_query.pop(); @@ -1627,7 +1638,7 @@ impl App { } if code == KeyCode::Char('/') { self.catalog_searching = true; - self.status = "Type to search • Enter applies • Esc leaves search".into(); + self.status = "Type to search · results update live · Esc leaves search".into(); return; } if code == KeyCode::Char('r') { @@ -1811,18 +1822,11 @@ impl App { } fn filtered_distribution_indices(&self) -> Vec { - let query = self.catalog_query.to_lowercase(); self.distributions .iter() .enumerate() .filter(|(_, distribution)| { - query.is_empty() - || distribution.name.to_lowercase().contains(&query) - || distribution.slug.to_lowercase().contains(&query) - || distribution - .based_on - .as_deref() - .is_some_and(|value| value.to_lowercase().contains(&query)) + distribution_matches_query(distribution, &self.catalog_query) }) .map(|(index, _)| index) .collect() @@ -1866,6 +1870,12 @@ impl App { } self.distribution_directory.clone() }; + if !self.catalog_query.is_empty() && !self.distribution_directory.is_empty() { + self.status = catalog_search_summary( + &self.catalog_query, + self.filtered_distribution_indices().len(), + ); + } if let Some(index) = self.filtered_distribution_indices().first() { self.catalog_selected = *index; } @@ -2582,7 +2592,8 @@ impl App { && contains(self.hit_regions.catalog_search, point) { self.catalog_searching = true; - self.status = "Type to search • Enter applies • Esc leaves search".into(); + self.status = + "Type to search · results update live · Esc leaves search".into(); } else if contains(self.hit_regions.source_distrowatch, point) { self.show_quick_access(QuickAccess::All); } else if contains(self.hit_regions.source_arch, point) { @@ -2959,7 +2970,7 @@ fn draw_collapsed_discovery(frame: &mut ratatui::Frame<'_>, app: &mut App, area: render_button( frame, area, - "+ Discover images · All · Arch · Debian · Omarchy · Windows · Raspberry Pi", + "+ Discover images · Browse trusted catalogs · Open →", app.workspace_focus == WorkspaceFocus::Discover, ); app.hit_regions.discover = Some(area); @@ -3486,7 +3497,7 @@ fn brand_lockup<'a>(wide: bool, context: &'a str, subtitle: &'a str) -> Vec(wide: bool, context: &'a str, subtitle: &'a str) -> Vec, app: &mut App, area: Rect) { let search_value = if app.discovery_session.quick_access() == QuickAccess::Windows { "Windows installer workflow · select an ISO to unlock every setup checkbox".into() } else if app.catalog_query.is_empty() { - "Search distributions… / to type".into() + "Search by name, slug, or base family… / to type".into() } else { format!( "{}{}", @@ -3816,7 +3827,28 @@ fn draw_catalog(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { let actions = Layout::horizontal([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) .spacing(1) .split(rows[2]); - render_button(frame, actions[0], "↻ Retry", false); + let active_state = match app.discovery_session.source() { + DiscoverySource::RaspberryPi => app.discovery_session.state(CatalogFacet::RaspberryPi), + DiscoverySource::DistroWatch if !app.catalog_query.is_empty() => { + app.discovery_session.state(CatalogFacet::Directory) + } + DiscoverySource::DistroWatch => match app.discovery_session.quick_access() { + QuickAccess::Arch => app.discovery_session.state(CatalogFacet::Arch), + QuickAccess::Debian => app.discovery_session.state(CatalogFacet::Debian), + _ => app.discovery_session.state(CatalogFacet::Popular), + }, + }; + let refresh_label = if active_state.is_failed() + || app + .discovery_session + .state(CatalogFacet::Details) + .is_failed() + { + "↻ Retry" + } else { + "↻ Refresh" + }; + render_button(frame, actions[0], refresh_label, false); let open_page_fallback = app.discovery_session.source() == DiscoverySource::DistroWatch && app.discovery_session.quick_access() != QuickAccess::Windows && app.catalog_releases.is_empty() @@ -4075,7 +4107,7 @@ fn draw_distrowatch_catalog(frame: &mut ratatui::Frame<'_>, app: &mut App, area: app.discovery_session.state(CatalogFacet::Directory), CatalogState::Ready { .. } | CatalogState::Empty ) { - "No matching distributions".into() + catalog_search_summary(&app.catalog_query, 0) } else if !app.catalog_query.is_empty() { app.discovery_session .state(CatalogFacet::Directory) @@ -4087,13 +4119,34 @@ fn draw_distrowatch_catalog(frame: &mut ratatui::Frame<'_>, app: &mut App, area: } else { matching_indices .iter() - .filter_map(|index| app.distributions.get(*index)) - .map(|distribution| { - ListItem::new(if distribution.rank == 0 { - format!(" · {:<26} DistroWatch", distribution.name) + .filter_map(|index| { + app.distributions + .get(*index) + .map(|distribution| (*index, distribution)) + }) + .map(|(index, distribution)| { + let action = if app.catalog_selected == index { + "Selected" + } else { + "Select →" + }; + ListItem::new(if !app.catalog_query.is_empty() { + let rank = if distribution.rank == 0 { + "·".into() + } else { + distribution.rank.to_string() + }; + format!( + "{:>2} {:<18} {:<12} {action}", + rank, + distribution.name, + distribution.based_on.as_deref().unwrap_or("Independent") + ) + } else if distribution.rank == 0 { + format!(" · {:<22} {action}", distribution.name) } else { format!( - "{:>2} {:<20} {:>5}", + "{:>2} {:<16} {:>5}/day {action}", distribution.rank, distribution.name, distribution.hits_per_day ) }) @@ -4135,9 +4188,19 @@ fn draw_distrowatch_catalog(frame: &mut ratatui::Frame<'_>, app: &mut App, area: .with_selected((!matching_indices.is_empty()).then_some(selected_position)); let mut release_state = ListState::default() .with_selected((!app.catalog_releases.is_empty()).then_some(app.release_selected)); + let distribution_title = if !app.catalog_query.is_empty() { + " Search results " + } else { + match app.discovery_session.quick_access() { + QuickAccess::Arch => " Arch-based ", + QuickAccess::Debian => " Debian-based ", + QuickAccess::Omarchy => " Omarchy ", + _ => " Popular · six months ", + } + }; frame.render_stateful_widget( List::new(distributions) - .block(panel_block(" Popular distributions ")) + .block(panel_block(distribution_title)) .style(Style::default().fg(Color::White)) .highlight_symbol("› ") .highlight_style(catalog_highlight( @@ -4254,8 +4317,13 @@ fn draw_pi_catalog(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { .position(|(index, _)| *index == app.pi_image_selected) .unwrap_or_default(); let image_items = if visible_images.is_empty() { - let message = if app.pi_catalog.is_some() { - "No compatible images".into() + let message = if app.pi_catalog.is_some() && !app.catalog_query.is_empty() { + format!( + "No Raspberry Pi images match “{}”", + app.catalog_query.trim() + ) + } else if app.pi_catalog.is_some() { + "No compatible Raspberry Pi images found".into() } else { app.discovery_session .state(CatalogFacet::RaspberryPi) @@ -4590,13 +4658,21 @@ fn draw_advanced(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { fn draw_targets(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { let items = if app.devices.is_empty() { vec![ - ListItem::new("Connect a removable USB drive, then refresh") + ListItem::new("Connect a removable USB or SD drive, then refresh") .style(Style::default().fg(MUTED)), ] } else { app.devices .iter() - .map(|device| { + .enumerate() + .map(|(index, device)| { + let action = if !device.is_eligible_target() { + "Blocked" + } else if app.selected == Some(index) { + "Selected" + } else { + "Select →" + }; ListItem::new(Line::from(vec![ Span::styled( format!("{:<12}", device.path.display()), @@ -4607,7 +4683,7 @@ fn draw_targets(frame: &mut ratatui::Frame<'_>, app: &mut App, area: Rect) { }), ), Span::raw(format!( - " {:>9} {} · {}", + " {:>9} {} · {} · {action}", format_bytes(device.capacity), device.display_name(), target_eligibility_label(device) @@ -4926,10 +5002,13 @@ fn windows_option_columns(width: u16) -> usize { fn draw_terminal_too_small(frame: &mut ratatui::Frame<'_>, area: Rect) { frame.render_widget( - Paragraph::new("┌┬┬┐ BOOTABLE α\n╰♨─╯\n\nResize to at least 44 × 22\nq Quit") - .alignment(Alignment::Center) - .style(Style::default().fg(Color::White)) - .block(panel_block(" Terminal too small ")), + Paragraph::new(format!( + "┌┬┬┐ BOOTABLE v{}\n╰♨─╯\n\nResize to at least 44 × 22\nq Quit", + env!("CARGO_PKG_VERSION") + )) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::White)) + .block(panel_block(" Terminal too small ")), area, ); } @@ -5076,7 +5155,11 @@ mod layout_tests { fn terminal_brand_matches_the_download_to_drive_logo() { let lines = brand_lockup(true, "Create boot media", "Deliberate writing"); assert_eq!(lines.len(), 2); - assert!(lines[0].to_string().contains("┌┬┬┐ BOOTABLE α")); + assert!( + lines[0] + .to_string() + .contains(&format!("┌┬┬┐ BOOTABLE v{}", env!("CARGO_PKG_VERSION"))) + ); assert!(lines[1].to_string().contains("╰♨─╯")); } diff --git a/crates/bootable-core/src/catalog.rs b/crates/bootable-core/src/catalog.rs index 8e078b6..62708de 100644 --- a/crates/bootable-core/src/catalog.rs +++ b/crates/bootable-core/src/catalog.rs @@ -85,6 +85,50 @@ pub struct DistributionBundle { pub warnings: Vec, } +/// Match the catalog fields exposed by both interactive adapters. +/// +/// One- and two-character terms match word prefixes only. This keeps live +/// typeahead useful without treating `om` as a match inside `ChromeOS`. +pub fn distribution_matches_query(distribution: &DistributionSummary, query: &str) -> bool { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return true; + } + + let fields = [ + Some(distribution.name.as_str()), + Some(distribution.slug.as_str()), + distribution.based_on.as_deref(), + ]; + query.split_whitespace().all(|term| { + fields + .iter() + .flatten() + .any(|field| catalog_field_matches(field, term)) + }) +} + +pub fn catalog_search_summary(query: &str, matches: usize) -> String { + let query = query.trim(); + match matches { + 0 => format!("No distributions match “{query}” · try a name or base family"), + 1 => format!("1 distribution matches “{query}” · name, slug, or base family"), + matches => { + format!("{matches} distributions match “{query}” · name, slug, or base family") + } + } +} + +fn catalog_field_matches(field: &str, term: &str) -> bool { + let field = field.to_lowercase(); + if term.chars().count() > 2 { + return field.contains(term); + } + field + .split(|character: char| !character.is_alphanumeric()) + .any(|word| word.starts_with(term)) +} + pub(crate) fn popular_distributions(limit: usize) -> Result> { let html = fetch_text(POPULARITY_URL)?; parse_popularity(&html, limit) @@ -1386,6 +1430,40 @@ mod tests { assert_eq!(entries[1].rank, 0); } + #[test] + fn short_catalog_queries_match_word_prefixes_not_incidental_substrings() { + let omarchy = + distribution_summary(18, "Omarchy".into(), "omarchy", 100, Some("Arch".into())); + let chromeos = + distribution_summary(29, "ChromeOS".into(), "chromeos", 80, Some("Gentoo".into())); + let fydeos = distribution_summary( + 40, + "FydeOS".into(), + "fydeos", + 60, + Some("Gentoo, ChromeOS".into()), + ); + + assert!(distribution_matches_query(&omarchy, "om")); + assert!(!distribution_matches_query(&chromeos, "om")); + assert!(!distribution_matches_query(&fydeos, "om")); + assert!(distribution_matches_query(&chromeos, "rome")); + assert!(distribution_matches_query(&fydeos, "chrome")); + assert!(distribution_matches_query(&omarchy, "om arch")); + } + + #[test] + fn search_summary_names_the_query_and_fields() { + assert_eq!( + catalog_search_summary("om", 0), + "No distributions match “om” · try a name or base family" + ); + assert_eq!( + catalog_search_summary("om", 1), + "1 distribution matches “om” · name, slug, or base family" + ); + } + #[test] fn base_results_follow_popularity_then_name() { let mut entries = vec![ diff --git a/crates/bootable-core/src/catalog_cache.rs b/crates/bootable-core/src/catalog_cache.rs index 9d334e0..365c94c 100644 --- a/crates/bootable-core/src/catalog_cache.rs +++ b/crates/bootable-core/src/catalog_cache.rs @@ -67,6 +67,10 @@ impl CatalogState { matches!(self, Self::Loading) } + pub fn is_failed(&self) -> bool { + matches!(self, Self::Failed(_)) + } + pub fn from_fetch(fetch: &CatalogFetch, empty: bool) -> Self { if empty { Self::Empty diff --git a/crates/bootable-core/src/lib.rs b/crates/bootable-core/src/lib.rs index 39c0fce..612e37e 100644 --- a/crates/bootable-core/src/lib.rs +++ b/crates/bootable-core/src/lib.rs @@ -26,7 +26,10 @@ mod write_session; use std::path::Path; -pub use catalog::{DistributionBundle, DistributionDetails, DistributionSummary, IsoRelease}; +pub use catalog::{ + DistributionBundle, DistributionDetails, DistributionSummary, IsoRelease, + catalog_search_summary, distribution_matches_query, +}; pub use catalog_cache::{CacheMode, CatalogFetch, CatalogOrigin, CatalogState}; pub use checksum::{Checksum, ChecksumAlgorithm}; pub use discovery_session::{CatalogFacet, DiscoverySession, DiscoverySource, QuickAccess}; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bcff5b5..8d6b078 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -25,6 +25,8 @@ unused space may separate those regions but must not appear after an arbitrary d Neither adapter implicitly selects a target. Keyboard navigation, mouse input, or pointer input must produce an explicit selection, and blocked/internal/system/read-only drives remain unselectable. +Both adapters show the same removable-media inventory summary before the device rows, followed by +the physical path, display name, capacity, eligibility, and explicit Select/Selected state. ## Shared discovery states @@ -40,6 +42,11 @@ Both adapters use `DiscoverySession`, `CatalogState`, `CatalogFetch`, and `Cache `bootable-core`. The session suppresses duplicate loads and rejects a response whose distribution slug is no longer selected. +Distribution search is live in both adapters and covers the name, slug, and base family. One- and +two-character terms match word prefixes so a query such as `om` finds Omarchy without treating the +middle of ChromeOS as an equally useful result. Empty results repeat the query, and failed states +rename the refresh action to Retry. + ## Cache rules - Remote discovery is cached for 30 minutes under the operating system temporary directory.