diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml index dff7cba..85f684a 100644 --- a/.github/workflows/release-windows.yml +++ b/.github/workflows/release-windows.yml @@ -110,6 +110,8 @@ jobs: cp -r /clang64/share/themes "$bundle/share/" || true mkdir -p "$bundle/share/aetheris" cp data/style.css "$bundle/share/aetheris/style.css" + mkdir -p "$bundle/share/icons/hicolor/scalable/actions" + cp data/icons/hicolor/scalable/actions/*.svg "$bundle/share/icons/hicolor/scalable/actions/" mkdir -p "$bundle/share/locale/pt_BR/LC_MESSAGES" cp po/pt_BR/LC_MESSAGES/org.luminusos.Aetheris.mo "$bundle/share/locale/pt_BR/LC_MESSAGES/org.luminusos.Aetheris.mo" cp -r /clang64/lib/gdk-pixbuf-2.0 "$bundle/lib/" || true diff --git a/build-aux/org.luminusos.Aetheris.json b/build-aux/org.luminusos.Aetheris.json index aaa9d4d..3e9618d 100644 --- a/build-aux/org.luminusos.Aetheris.json +++ b/build-aux/org.luminusos.Aetheris.json @@ -64,7 +64,8 @@ "install -Dm644 data/icons/hicolor/256x256/apps/org.luminusos.Aetheris.png /app/share/icons/hicolor/256x256/apps/org.luminusos.Aetheris.png", "install -Dm644 data/icons/hicolor/512x512/apps/org.luminusos.Aetheris.png /app/share/icons/hicolor/512x512/apps/org.luminusos.Aetheris.png", "install -Dm644 data/icons/hicolor/symbolic/apps/org.luminusos.Aetheris-symbolic.svg /app/share/icons/hicolor/symbolic/apps/org.luminusos.Aetheris-symbolic.svg", - "install -Dm644 data/icons/hicolor/scalable/actions/nautilus-search-filters-symbolic.svg /app/share/icons/hicolor/scalable/actions/nautilus-search-filters-symbolic.svg" + "mkdir -p /app/share/icons/hicolor/scalable/actions", + "cp data/icons/hicolor/scalable/actions/*.svg /app/share/icons/hicolor/scalable/actions/" ], "sources": [{ "type": "dir", "path": ".." }] } diff --git a/crates/aetheris-app/Cargo.toml b/crates/aetheris-app/Cargo.toml index dd79240..9d78174 100644 --- a/crates/aetheris-app/Cargo.toml +++ b/crates/aetheris-app/Cargo.toml @@ -21,6 +21,7 @@ assets = [ "../../data/icons/hicolor/256x256/apps/org.luminusos.Aetheris.png", "../../data/icons/hicolor/512x512/apps/org.luminusos.Aetheris.png", "../../data/icons/hicolor/symbolic/apps/org.luminusos.Aetheris-symbolic.svg", + "../../data/icons/hicolor/scalable/actions", "../../po/pt_BR/LC_MESSAGES/org.luminusos.Aetheris.mo", ] auto_link = true diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index fd3be38..ee7123e 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -37,8 +37,8 @@ mod yaml; use i18n::{tr, tr_format, trn}; use projects::{ - DetailTarget, ObjectColumn, ObjectTableColumn, PodLogTarget, Project, ProjectStore, - ResourceSection, StatusFilter, + DetailTarget, ObjectColumn, ObjectFavorite, ObjectTableColumn, PodLogTarget, Project, + ProjectStore, ResourceSection, StatusFilter, }; use widgets::{ is_access_resource, is_cluster_resource, is_configuration_resource, is_network_resource, @@ -83,6 +83,7 @@ pub(super) struct DetailPane { apply_button: gtk::Button, download_yaml_button: gtk::Button, delete_button: gtk::Button, + favorite_button: gtk::Button, terminal_button: gtk::Button, yaml_buffer: sourceview5::Buffer, events_list: gtk::ListBox, @@ -168,6 +169,7 @@ pub struct App { status_label: gtk::Label, spinner: gtk::Spinner, resource_list: gtk::ListBox, + favorite_object_list: gtk::ListBox, /// Backing model for `object_view`. The `ColumnView` only realizes /// widgets for on-screen rows, so this can hold tens of thousands of /// objects without the widget tree growing with it. @@ -254,7 +256,9 @@ pub enum AppMsg { ObjectColumnToggled(u32), ObjectColumnResized(ObjectTableColumn, i32), EditCurrentCluster, - ResourceChanged(usize), + ResourceChanged(usize, ResourceSection), + ToggleCurrentObjectFavorite, + FavoriteObjectActivated(ObjectFavorite), SearchChanged(String), ObjectActivated(i32), RelatedPodActivated(i32), diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 71123a8..ef472bb 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -228,6 +228,9 @@ impl Component for App { let resource_list = gtk::ListBox::new(); resource_list.add_css_class("boxed-list"); resource_list.set_selection_mode(gtk::SelectionMode::None); + let favorite_object_list = gtk::ListBox::new(); + favorite_object_list.add_css_class("boxed-list"); + favorite_object_list.set_selection_mode(gtk::SelectionMode::None); let object_store = gtk::gio::ListStore::new::(); let object_view = gtk::ColumnView::builder() .single_click_activate(true) @@ -333,6 +336,18 @@ impl Component for App { detail_delete_button.add_css_class("flat"); detail_delete_button.set_size_request(34, 34); detail_delete_button.set_valign(gtk::Align::Center); + let detail_favorite_button = gtk::Button::builder() + .icon_name(super::widgets::available_icon_name( + "aetheris-object-favorite-outline-symbolic", + "non-starred-symbolic", + )) + .tooltip_text(tr("Add to favorites")) + .sensitive(false) + .visible(false) + .build(); + detail_favorite_button.add_css_class("flat"); + detail_favorite_button.set_size_request(34, 34); + detail_favorite_button.set_valign(gtk::Align::Center); let detail_terminal_button = gtk::Button::builder() .icon_name("utilities-terminal-symbolic") .tooltip_text(tr("Open terminal")) @@ -606,11 +621,13 @@ impl Component for App { cluster_menu_button: &cluster_menu_button, namespace_menu_button: &namespace_menu_button, resource_list: &resource_list, + favorite_object_list: &favorite_object_list, }); let content = build_content(ContentWidgets { sidebar_toggle_button: &sidebar_toggle_button, detail_back_button: &detail_back_button, delete_button: &detail_delete_button, + favorite_button: &detail_favorite_button, create_yaml_button: &create_yaml_button, refresh_button: &refresh_button, terminal_button: &detail_terminal_button, @@ -813,6 +830,10 @@ impl Component for App { let sender = sender.clone(); move |_| sender.input(AppMsg::DeleteObject) }); + detail_favorite_button.connect_clicked({ + let sender = sender.clone(); + move |_| sender.input(AppMsg::ToggleCurrentObjectFavorite) + }); detail_terminal_button.connect_clicked({ let sender = sender.clone(); move |_| sender.input(AppMsg::ShowPodTerminal) @@ -916,6 +937,7 @@ impl Component for App { status_label, spinner, resource_list, + favorite_object_list, object_store, object_sorted, object_columns, @@ -940,6 +962,7 @@ impl Component for App { apply_button: detail_apply_button, download_yaml_button: detail_download_yaml_button, delete_button: detail_delete_button, + favorite_button: detail_favorite_button, terminal_button: detail_terminal_button, yaml_buffer: detail_yaml_buffer, events_list: detail_events_list, diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index bde1f00..4771847 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -491,22 +491,40 @@ impl App { self.schedule_project_save(&sender); } } - AppMsg::ResourceChanged(index) => { - if self.resources.get(index).is_some() { - let next = Some(index); - if self.selected_resource != next { - self.selected_resource = next; - self.selected_resource_section = - ResourceSection::for_resource(&self.resources[index]); - self.rebuild_resource_list(Some(sender.clone())); - self.present_content_panel(); - self.show_object_list(); - self.sync_object_columns(); - self.stop_log_stream(); - self.stop_port_forward(); - self.refresh_objects(sender); - } + AppMsg::ResourceChanged(index, _section) => { + let Some(resource) = self.resources.get(index) else { + return; + }; + if self.selected_resource == Some(index) { + return; } + self.selected_resource = Some(index); + self.selected_resource_section = ResourceSection::for_resource(resource); + self.rebuild_resource_list(Some(sender.clone())); + self.present_content_panel(); + self.show_object_list(); + self.sync_object_columns(); + self.stop_log_stream(); + self.stop_port_forward(); + self.refresh_objects(sender); + } + AppMsg::ToggleCurrentObjectFavorite => { + let Some(target) = self.detail.target.clone() else { + return; + }; + self.projects.toggle_object_favorite(&target); + self.save_projects_or_toast(); + self.sync_detail_favorite_button(); + self.rebuild_favorite_object_list(Some(sender.clone())); + } + AppMsg::FavoriteObjectActivated(favorite) => { + self.open_object_detail( + favorite.context.clone(), + favorite.resource(), + favorite.namespace(), + favorite.name.clone(), + sender, + ); } AppMsg::SearchChanged(query) => { self.search_query = query; @@ -556,13 +574,19 @@ impl App { return; } self.loading = false; - self.detail.target = None; self.detail.log_target = None; self.detail.exec_target = None; self.detail.port_forward_target = None; self.sync_log_controls(); - self.sync_terminal_controls(); - self.sync_port_forward_controls(); + // Keep `target` set (rather than clearing it) and still open + // the detail page: the object is gone, but this is the only + // place with a favorite/star button, so a stale favorite + // must be reachable here to unfavorite it. + if let Some(target) = self.detail.target.clone() { + let placeholder = unavailable_object_detail(&target); + self.populate_detail_dialog(&placeholder); + self.show_detail_page(&placeholder.name); + } self.status = tr("Unable to load object detail."); self.sync_status(); self.toaster.add_toast(adw::Toast::new(&error)); @@ -617,6 +641,10 @@ impl App { self.show_object_list(); self.stop_log_stream(); self.stop_port_forward(); + self.detail.target = None; + self.detail.log_target = None; + self.detail.exec_target = None; + self.detail.port_forward_target = None; self.sync_log_controls(); self.sync_terminal_controls(); self.sync_port_forward_controls(); diff --git a/crates/aetheris-app/src/app/layout.rs b/crates/aetheris-app/src/app/layout.rs index 5ced212..bcc0fe5 100644 --- a/crates/aetheris-app/src/app/layout.rs +++ b/crates/aetheris-app/src/app/layout.rs @@ -7,6 +7,7 @@ pub(super) struct SidebarWidgets<'a> { pub(super) cluster_menu_button: &'a gtk::MenuButton, pub(super) namespace_menu_button: &'a gtk::MenuButton, pub(super) resource_list: &'a gtk::ListBox, + pub(super) favorite_object_list: &'a gtk::ListBox, } pub(super) fn build_sidebar(widgets: SidebarWidgets<'_>) -> adw::NavigationPage { @@ -29,10 +30,69 @@ pub(super) fn build_sidebar(widgets: SidebarWidgets<'_>) -> adw::NavigationPage container.append(&namespace_group); - let resources_group = gtk::Box::new(gtk::Orientation::Vertical, 8); - resources_group.append(§ion_title(&tr("Resources"))); - resources_group.append(widgets.resource_list); - container.append(&resources_group); + let resources_page = gtk::Box::new(gtk::Orientation::Vertical, 8); + resources_page.append(widgets.resource_list); + + let favorites_page = gtk::Box::new(gtk::Orientation::Vertical, 8); + favorites_page.append(widgets.favorite_object_list); + + let sidebar_stack = gtk::Stack::builder() + .hhomogeneous(false) + .vhomogeneous(false) + .build(); + sidebar_stack.add_named(&resources_page, Some("resources")); + sidebar_stack.add_named(&favorites_page, Some("favorites")); + + let toggle_box = gtk::Box::new(gtk::Orientation::Horizontal, 4); + let resources_toggle = gtk::ToggleButton::builder() + .icon_name("view-list-symbolic") + .tooltip_text(tr("Resources")) + .hexpand(true) + .active(true) + .css_classes(["flat"]) + .build(); + let favorites_toggle = gtk::ToggleButton::builder() + .icon_name("aetheris-object-favorite-symbolic") + .tooltip_text(tr("Favorites")) + .hexpand(true) + .css_classes(["flat"]) + .build(); + toggle_box.append(&resources_toggle); + toggle_box.append(&favorites_toggle); + + resources_toggle.connect_clicked({ + let resources_toggle = resources_toggle.clone(); + move |_| resources_toggle.set_active(true) + }); + resources_toggle.connect_toggled({ + let favorites_toggle = favorites_toggle.clone(); + let sidebar_stack = sidebar_stack.clone(); + move |button| { + if button.is_active() { + favorites_toggle.set_active(false); + sidebar_stack.set_visible_child_name("resources"); + } + } + }); + favorites_toggle.connect_clicked({ + let favorites_toggle = favorites_toggle.clone(); + move |_| favorites_toggle.set_active(true) + }); + favorites_toggle.connect_toggled({ + let resources_toggle = resources_toggle.clone(); + let sidebar_stack = sidebar_stack.clone(); + move |button| { + if button.is_active() { + resources_toggle.set_active(false); + sidebar_stack.set_visible_child_name("favorites"); + } + } + }); + + let objects_group = gtk::Box::new(gtk::Orientation::Vertical, 8); + objects_group.append(&toggle_box); + objects_group.append(&sidebar_stack); + container.append(&objects_group); let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) @@ -50,6 +110,7 @@ pub(super) struct ContentWidgets<'a> { pub(super) sidebar_toggle_button: &'a gtk::ToggleButton, pub(super) detail_back_button: &'a gtk::Button, pub(super) delete_button: &'a gtk::Button, + pub(super) favorite_button: &'a gtk::Button, pub(super) create_yaml_button: &'a gtk::Button, pub(super) refresh_button: &'a gtk::Button, pub(super) terminal_button: &'a gtk::Button, @@ -101,6 +162,7 @@ pub(super) fn build_content(widgets: ContentWidgets<'_>) -> adw::NavigationPage detail_actions.set_valign(gtk::Align::Center); detail_actions.set_halign(gtk::Align::End); detail_actions.append(widgets.terminal_button); + detail_actions.append(widgets.favorite_button); detail_actions.append(widgets.delete_button); header.pack_end(&detail_actions); toolbar.add_top_bar(&header); diff --git a/crates/aetheris-app/src/app/methods.rs b/crates/aetheris-app/src/app/methods.rs index dd30020..7e1c622 100644 --- a/crates/aetheris-app/src/app/methods.rs +++ b/crates/aetheris-app/src/app/methods.rs @@ -72,6 +72,7 @@ impl App { self.content_header_stack.set_visible_child_name("search"); self.detail.back_button.set_visible(false); self.detail.delete_button.set_visible(false); + self.detail.favorite_button.set_visible(false); self.detail.terminal_button.set_visible(false); } @@ -112,6 +113,8 @@ impl App { self.content_header_stack.set_visible_child_name("title"); self.detail.back_button.set_visible(true); self.detail.delete_button.set_visible(true); + self.detail.favorite_button.set_visible(true); + self.sync_detail_favorite_button(); self.sync_terminal_controls(); } @@ -476,6 +479,9 @@ impl App { self.detail .delete_button .set_sensitive(self.detail.target.is_some() && !self.loading); + self.detail + .favorite_button + .set_sensitive(self.detail.target.is_some() && !self.loading); self.detail.terminal_button.set_sensitive( self.detail .exec_target @@ -562,6 +568,7 @@ impl App { .subtitle(tr("Connect to a cluster to load API resources.")) .build(); self.resource_list.append(&row); + self.rebuild_favorite_object_list(sender); return; } @@ -578,12 +585,72 @@ impl App { for (resource_index, resource) in resources { let child = resource_row(resource, self.selected_resource == Some(resource_index)); - connect_resource_row(&child, sender.clone(), resource_index); + connect_resource_row(&child, sender.clone(), resource_index, section); row.add_row(&child); } self.resource_list.append(&row); } + self.rebuild_favorite_object_list(sender); + } + + pub(super) fn rebuild_favorite_object_list(&self, sender: Option>) { + while let Some(child) = self.favorite_object_list.first_child() { + self.favorite_object_list.remove(&child); + } + + let Some(context) = self.selected_context.as_deref() else { + let row = adw::ActionRow::builder() + .title(tr("No favorites")) + .subtitle(tr("Select a cluster to show favorite objects.")) + .build(); + self.favorite_object_list.append(&row); + return; + }; + let favorites = self.projects.favorite_objects_for_context(context); + + if favorites.is_empty() { + let row = adw::ActionRow::builder() + .title(tr("No favorites")) + .subtitle(tr("Open an object and star it to keep it here.")) + .build(); + self.favorite_object_list.append(&row); + return; + } + + for favorite in favorites { + let row = favorite_object_row(&favorite); + connect_favorite_object_row(&row, sender.clone(), favorite); + self.favorite_object_list.append(&row); + } + } + + pub(super) fn sync_detail_favorite_button(&self) { + let favorited = self + .detail + .target + .as_ref() + .is_some_and(|target| self.projects.is_object_favorite(target)); + self.detail + .favorite_button + .set_icon_name(available_icon_name( + if favorited { + "aetheris-object-favorite-symbolic" + } else { + "aetheris-object-favorite-outline-symbolic" + }, + if favorited { + "starred-symbolic" + } else { + "non-starred-symbolic" + }, + )); + let tooltip = if favorited { + tr("Remove from favorites") + } else { + tr("Add to favorites") + }; + self.detail.favorite_button.set_tooltip_text(Some(&tooltip)); } /// Replaces the object table's backing model with the current filtered @@ -769,6 +836,7 @@ impl App { .unwrap_or("-"), ); self.detail.yaml_buffer.set_text(&detail.yaml); + self.sync_detail_favorite_button(); self.detail.node_unschedulable = detail.node_unschedulable; self.detail .scale_spin diff --git a/crates/aetheris-app/src/app/projects.rs b/crates/aetheris-app/src/app/projects.rs index 810c85b..095b422 100644 --- a/crates/aetheris-app/src/app/projects.rs +++ b/crates/aetheris-app/src/app/projects.rs @@ -14,6 +14,8 @@ pub struct ProjectStore { pub(super) object_name_width: Option, #[serde(default)] pub(super) object_column_widths: Vec, + #[serde(default)] + pub(super) favorite_objects: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -53,7 +55,7 @@ pub(super) struct PodLogTarget { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ResourceSection { +pub(crate) enum ResourceSection { Workloads, Network, Storage, @@ -96,6 +98,64 @@ pub(super) struct ObjectColumnWidth { width: i32, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ObjectFavorite { + pub(super) context: String, + group: String, + version: String, + api_version: String, + kind: String, + plural: String, + namespace: Option, + pub(super) name: String, +} + +impl ObjectFavorite { + fn from_target(target: &DetailTarget) -> Self { + Self { + context: target.context.clone(), + group: target.resource.group.clone(), + version: target.resource.version.clone(), + api_version: target.resource.api_version.clone(), + kind: target.resource.kind.clone(), + plural: target.resource.plural.clone(), + namespace: target.namespace.clone(), + name: target.name.clone(), + } + } + + fn matches_target(&self, target: &DetailTarget) -> bool { + self.context == target.context + && self.group == target.resource.group + && self.kind == target.resource.kind + && self.namespace == target.namespace + && self.name == target.name + } + + pub(super) fn resource(&self) -> ResourceKind { + ResourceKind { + group: self.group.clone(), + version: self.version.clone(), + api_version: self.api_version.clone(), + kind: self.kind.clone(), + plural: self.plural.clone(), + scope: if self.namespace.is_some() { + aetheris_kube::ResourceScope::Namespaced + } else { + aetheris_kube::ResourceScope::Cluster + }, + } + } + + pub(super) fn namespace(&self) -> Option { + self.namespace.clone() + } + + pub(super) fn kind(&self) -> &str { + &self.kind + } +} + impl ObjectColumn { pub(super) const ALL: [Self; 7] = [ Self::Image, @@ -273,6 +333,7 @@ impl ProjectStore { visible_object_columns: default_object_columns(), object_name_width: None, object_column_widths: Vec::new(), + favorite_objects: Vec::new(), } } @@ -289,6 +350,7 @@ impl ProjectStore { visible_object_columns: default_object_columns(), object_name_width: None, object_column_widths: Vec::new(), + favorite_objects: Vec::new(), } } @@ -300,6 +362,7 @@ impl ProjectStore { store.normalize_object_columns(); store.normalize_object_column_widths(); store.normalize_object_name_width(); + store.normalize_favorite_objects(); store.normalize_contexts(contexts); store.normalize_last_namespaces(contexts); if let Err(error) = store.save() { @@ -337,6 +400,34 @@ impl ProjectStore { self.normalize_object_columns(); } + pub(super) fn favorite_objects_for_context(&self, context: &str) -> Vec { + self.favorite_objects + .iter() + .filter(|favorite| favorite.context == context) + .cloned() + .collect() + } + + pub(super) fn is_object_favorite(&self, target: &DetailTarget) -> bool { + self.favorite_objects + .iter() + .any(|favorite| favorite.matches_target(target)) + } + + pub(super) fn toggle_object_favorite(&mut self, target: &DetailTarget) -> bool { + let favorite = ObjectFavorite::from_target(target); + let previous_len = self.favorite_objects.len(); + self.favorite_objects + .retain(|existing| existing != &favorite); + + if previous_len == self.favorite_objects.len() { + self.favorite_objects.push(favorite); + } + + self.normalize_favorite_objects(); + true + } + pub(super) fn object_column_width(&self, column: ObjectColumn) -> i32 { self.object_column_widths .iter() @@ -485,6 +576,36 @@ impl ProjectStore { .filter(|width| *width != OBJECT_NAME_WIDTH); } + fn normalize_favorite_objects(&mut self) { + for favorite in &mut self.favorite_objects { + favorite.context = favorite.context.trim().to_owned(); + favorite.group = favorite.group.trim().to_owned(); + favorite.version = favorite.version.trim().to_owned(); + favorite.api_version = favorite.api_version.trim().to_owned(); + favorite.kind = favorite.kind.trim().to_owned(); + favorite.plural = favorite.plural.trim().to_owned(); + favorite.namespace = favorite + .namespace + .as_deref() + .map(str::trim) + .filter(|namespace| !namespace.is_empty()) + .map(str::to_owned); + favorite.name = favorite.name.trim().to_owned(); + } + self.favorite_objects.retain(|favorite| { + !favorite.context.is_empty() && !favorite.kind.is_empty() && !favorite.name.is_empty() + }); + self.favorite_objects.sort_by(|left, right| { + left.context + .cmp(&right.context) + .then(left.group.cmp(&right.group)) + .then(left.kind.cmp(&right.kind)) + .then(left.namespace.cmp(&right.namespace)) + .then(left.name.cmp(&right.name)) + }); + self.favorite_objects.dedup(); + } + pub(super) fn save(&self) -> Result<(), String> { let Some(path) = Self::path() else { return Ok(()); @@ -749,6 +870,7 @@ impl Project { #[cfg(test)] mod tests { use super::*; + use aetheris_kube::ResourceScope; fn context(name: &str) -> ContextInfo { ContextInfo { @@ -762,6 +884,21 @@ mod tests { } } + fn resource(group: &str, kind: &str) -> ResourceKind { + ResourceKind { + group: group.to_owned(), + version: String::from("v1"), + api_version: if group.is_empty() { + String::from("v1") + } else { + format!("{group}/v1") + }, + kind: kind.to_owned(), + plural: format!("{}s", kind.to_ascii_lowercase()), + scope: ResourceScope::Namespaced, + } + } + #[test] fn normalize_contexts_does_not_import_external_contexts() { let mut store = ProjectStore { @@ -776,6 +913,7 @@ mod tests { visible_object_columns: default_object_columns(), object_name_width: None, object_column_widths: Vec::new(), + favorite_objects: Vec::new(), }; store.normalize_contexts(&[context("local"), context("external")]); @@ -814,6 +952,7 @@ mod tests { visible_object_columns: default_object_columns(), object_name_width: None, object_column_widths: Vec::new(), + favorite_objects: Vec::new(), }; store.normalize_contexts(&[]); @@ -880,6 +1019,28 @@ mod tests { assert_eq!(store.object_name_width(), OBJECT_NAME_MIN_WIDTH); } + #[test] + fn toggle_object_favorite_tracks_object_by_context_resource_namespace_and_name() { + let mut store = ProjectStore::default(); + let target = DetailTarget { + context: String::from("prod"), + resource: resource("apps", "Deployment"), + namespace: Some(String::from("my-namespace")), + name: String::from("sample-deploy"), + }; + + assert!(!store.is_object_favorite(&target)); + assert!(store.toggle_object_favorite(&target)); + assert!(store.is_object_favorite(&target)); + assert_eq!( + store.favorite_objects_for_context("prod")[0].kind(), + "Deployment" + ); + + assert!(store.toggle_object_favorite(&target)); + assert!(!store.is_object_favorite(&target)); + } + #[test] fn normalize_contexts_prunes_last_namespaces_for_deleted_contexts() { let mut store = ProjectStore { @@ -903,6 +1064,7 @@ mod tests { visible_object_columns: default_object_columns(), object_name_width: None, object_column_widths: Vec::new(), + favorite_objects: Vec::new(), }; store.normalize_last_namespaces(&[context("prod")]); diff --git a/crates/aetheris-app/src/app/utils.rs b/crates/aetheris-app/src/app/utils.rs index ffb7478..a620a1e 100644 --- a/crates/aetheris-app/src/app/utils.rs +++ b/crates/aetheris-app/src/app/utils.rs @@ -278,6 +278,32 @@ pub(super) fn pod_main_image(images: &[String]) -> Option { .cloned() } +/// A placeholder `ObjectDetail` for a target whose load failed (e.g. a +/// favorited object that no longer exists in the cluster). Lets the detail +/// page still open so its favorite/star button is reachable, instead of +/// leaving a stale favorite with no way to unfavorite it. +pub(super) fn unavailable_object_detail(target: &DetailTarget) -> ObjectDetail { + ObjectDetail { + name: target.name.clone(), + namespace: target.namespace.clone().unwrap_or_default(), + status: tr("Unavailable"), + api_version: target.resource.api_version.clone(), + kind: target.resource.kind.clone(), + age: String::from("-"), + metrics: None, + container_metrics: Vec::new(), + container_resources: Vec::new(), + yaml: String::new(), + containers: Vec::new(), + related_pods: Vec::new(), + replicas: None, + node_unschedulable: None, + conditions: Vec::new(), + events: Vec::new(), + events_error: None, + } +} + #[cfg(test)] mod tests { use super::{forbidden_summary, offerable_columns_for, pod_main_image, shortened_image}; diff --git a/crates/aetheris-app/src/app/widgets.rs b/crates/aetheris-app/src/app/widgets.rs index 919ec2f..62170f2 100644 --- a/crates/aetheris-app/src/app/widgets.rs +++ b/crates/aetheris-app/src/app/widgets.rs @@ -483,13 +483,28 @@ pub(super) fn connect_resource_row( row: &adw::ActionRow, sender: Option>, resource_index: usize, + section: ResourceSection, ) { let Some(sender) = sender else { return; }; row.connect_activated(move |_| { - sender.input(AppMsg::ResourceChanged(resource_index)); + sender.input(AppMsg::ResourceChanged(resource_index, section)); + }); +} + +pub(super) fn connect_favorite_object_row( + row: &adw::ActionRow, + sender: Option>, + favorite: ObjectFavorite, +) { + let Some(sender) = sender else { + return; + }; + + row.connect_activated(move |_| { + sender.input(AppMsg::FavoriteObjectActivated(favorite.clone())); }); } @@ -507,6 +522,11 @@ pub(super) fn resource_row(resource: &ResourceKind, selected: bool) -> adw::Acti )) .activatable(true) .build(); + let icon = gtk::Image::from_icon_name(available_icon_name( + resource_icon_name(resource), + "application-x-addon-symbolic", + )); + row.add_prefix(&icon); if selected { row.add_css_class("resource-row-selected"); @@ -515,6 +535,90 @@ pub(super) fn resource_row(resource: &ResourceKind, selected: bool) -> adw::Acti row } +pub(super) fn favorite_object_row(favorite: &ObjectFavorite) -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(favorite.name.as_str()) + .subtitle(favorite.kind()) + .title_lines(1) + .subtitle_lines(1) + .activatable(true) + .tooltip_text(favorite.name.as_str()) + .build(); + let icon = gtk::Image::from_icon_name(available_icon_name( + resource_icon_name(&favorite.resource()), + "application-x-addon-symbolic", + )); + row.add_prefix(&icon); + row +} + +pub(super) fn resource_icon_name(resource: &ResourceKind) -> &'static str { + match resource.group.as_str() { + "" => match resource.kind.as_str() { + "Pod" => "lucide-box-symbolic", + "ConfigMap" => "lucide-file-sliders-symbolic", + "Secret" => "lucide-file-key-2-symbolic", + "Namespace" => "lucide-orbit-symbolic", + "Service" => "lucide-waypoints-symbolic", + "Node" => "lucide-server-symbolic", + "PersistentVolume" => "lucide-hard-drive-download-symbolic", + "PersistentVolumeClaim" => "lucide-hard-drive-upload-symbolic", + "Event" => "dialog-information-symbolic", + "ServiceAccount" => "lucide-user-symbolic", + _ => "lucide-blocks-symbolic", + }, + "apps" => match resource.kind.as_str() { + "ReplicaSet" => "lucide-layers-2-symbolic", + "Deployment" => "lucide-layers-3-symbolic", + "StatefulSet" => "lucide-database-symbolic", + "DaemonSet" => "lucide-server-cog-symbolic", + _ => "lucide-blocks-symbolic", + }, + "batch" => match resource.kind.as_str() { + "Job" => "lucide-cloud-cog-symbolic", + "CronJob" => "lucide-timer-reset-symbolic", + _ => "lucide-blocks-symbolic", + }, + "networking.k8s.io" => match resource.kind.as_str() { + "Ingress" => "lucide-radio-tower-symbolic", + "IngressClass" => "lucide-cast-symbolic", + "NetworkPolicy" => "lucide-globe-lock-symbolic", + _ => "lucide-blocks-symbolic", + }, + "events.k8s.io" => match resource.kind.as_str() { + "Event" => "dialog-information-symbolic", + _ => "lucide-blocks-symbolic", + }, + "apiextensions.k8s.io" => match resource.kind.as_str() { + "CustomResourceDefinition" => "lucide-toy-brick-symbolic", + _ => "lucide-blocks-symbolic", + }, + "storage.k8s.io" => match resource.kind.as_str() { + "CSIDriver" => "lucide-warehouse-symbolic", + "CSINode" => "lucide-cylinder-symbolic", + "StorageClass" => "lucide-import-symbolic", + _ => "lucide-blocks-symbolic", + }, + "helm.toolkit.fluxcd.io" => match resource.kind.as_str() { + "HelmRelease" => "lucide-package-open-symbolic", + _ => "lucide-blocks-symbolic", + }, + "source.toolkit.fluxcd.io" => match resource.kind.as_str() { + "HelmChart" => "lucide-map-symbolic", + "HelmRepository" => "lucide-library-symbolic", + "GitRepository" => "lucide-folder-git-symbolic", + "Bucket" => "lucide-paint-bucket-symbolic", + "OCIRepository" => "lucide-container-symbolic", + _ => "lucide-blocks-symbolic", + }, + "monitoring.coreos.com" => match resource.kind.as_str() { + "PodMonitor" => "lucide-package-search-symbolic", + _ => "lucide-blocks-symbolic", + }, + _ => "lucide-blocks-symbolic", + } +} + pub(super) fn is_workload_resource(resource: &ResourceKind) -> bool { matches!( resource.kind.as_str(), diff --git a/data/icons/hicolor/scalable/actions/aetheris-object-favorite-outline-symbolic.svg b/data/icons/hicolor/scalable/actions/aetheris-object-favorite-outline-symbolic.svg new file mode 100644 index 0000000..5a05a0e --- /dev/null +++ b/data/icons/hicolor/scalable/actions/aetheris-object-favorite-outline-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/hicolor/scalable/actions/aetheris-object-favorite-symbolic.svg b/data/icons/hicolor/scalable/actions/aetheris-object-favorite-symbolic.svg new file mode 100644 index 0000000..e855fe9 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/aetheris-object-favorite-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/data/icons/hicolor/scalable/actions/lucide-blocks-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-blocks-symbolic.svg new file mode 100644 index 0000000..10db25e --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-blocks-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-box-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-box-symbolic.svg new file mode 100644 index 0000000..361bb77 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-box-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-cast-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-cast-symbolic.svg new file mode 100644 index 0000000..22f8ee6 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-cast-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-cloud-cog-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-cloud-cog-symbolic.svg new file mode 100644 index 0000000..3840a99 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-cloud-cog-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-container-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-container-symbolic.svg new file mode 100644 index 0000000..f2b997f --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-container-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-cylinder-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-cylinder-symbolic.svg new file mode 100644 index 0000000..a16745b --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-cylinder-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-database-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-database-symbolic.svg new file mode 100644 index 0000000..16f0d47 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-database-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-file-key-2-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-file-key-2-symbolic.svg new file mode 100644 index 0000000..bc81472 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-file-key-2-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-file-sliders-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-file-sliders-symbolic.svg new file mode 100644 index 0000000..06752ee --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-file-sliders-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-folder-git-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-folder-git-symbolic.svg new file mode 100644 index 0000000..efcc296 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-folder-git-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-globe-lock-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-globe-lock-symbolic.svg new file mode 100644 index 0000000..fd60a33 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-globe-lock-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-hard-drive-download-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-hard-drive-download-symbolic.svg new file mode 100644 index 0000000..9b39a6f --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-hard-drive-download-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-hard-drive-upload-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-hard-drive-upload-symbolic.svg new file mode 100644 index 0000000..4f0c40e --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-hard-drive-upload-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-import-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-import-symbolic.svg new file mode 100644 index 0000000..d9d40e9 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-import-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-layers-2-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-layers-2-symbolic.svg new file mode 100644 index 0000000..2c983c9 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-layers-2-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-layers-3-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-layers-3-symbolic.svg new file mode 100644 index 0000000..a601b83 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-layers-3-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-library-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-library-symbolic.svg new file mode 100644 index 0000000..7443beb --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-library-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-map-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-map-symbolic.svg new file mode 100644 index 0000000..3e67195 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-map-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-orbit-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-orbit-symbolic.svg new file mode 100644 index 0000000..c512c46 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-orbit-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-package-open-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-package-open-symbolic.svg new file mode 100644 index 0000000..be9c51f --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-package-open-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-package-search-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-package-search-symbolic.svg new file mode 100644 index 0000000..2bfa634 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-package-search-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-paint-bucket-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-paint-bucket-symbolic.svg new file mode 100644 index 0000000..712c73f --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-paint-bucket-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-radio-tower-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-radio-tower-symbolic.svg new file mode 100644 index 0000000..61b6a10 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-radio-tower-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-server-cog-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-server-cog-symbolic.svg new file mode 100644 index 0000000..f21d4b1 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-server-cog-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-server-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-server-symbolic.svg new file mode 100644 index 0000000..53eae49 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-server-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-timer-reset-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-timer-reset-symbolic.svg new file mode 100644 index 0000000..72d6d25 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-timer-reset-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-toy-brick-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-toy-brick-symbolic.svg new file mode 100644 index 0000000..c8cfa6a --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-toy-brick-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-user-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-user-symbolic.svg new file mode 100644 index 0000000..30c9c08 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-user-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-warehouse-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-warehouse-symbolic.svg new file mode 100644 index 0000000..7c8652e --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-warehouse-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/scalable/actions/lucide-waypoints-symbolic.svg b/data/icons/hicolor/scalable/actions/lucide-waypoints-symbolic.svg new file mode 100644 index 0000000..92fa3c4 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/lucide-waypoints-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file