From 1b67f77dfad9775ab7a4145dad11d2d60505b751 Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 31 Mar 2026 16:19:19 +0200 Subject: [PATCH 1/2] feat: dedicated hook panel with auto-open/close and status tracking Move hook terminals from the layout tree into a dedicated toggleable panel (like the service panel). Hooks now show with status dots, rerun and dismiss buttons, and auto-open when hooks fire / auto-close when all succeed. Track hook command exit codes via OSC title sequence so the terminal stays interactive while status updates correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/okena-views-sidebar/src/hook_list.rs | 10 +- crates/okena-views-sidebar/src/sidebar.rs | 34 +- .../src/layout/split_pane.rs | 8 +- crates/okena-workspace/src/actions/folder.rs | 2 + crates/okena-workspace/src/actions/layout.rs | 1 + crates/okena-workspace/src/actions/project.rs | 8 + crates/okena-workspace/src/hooks.rs | 5 +- crates/okena-workspace/src/persistence.rs | 4 + crates/okena-workspace/src/requests.rs | 1 + crates/okena-workspace/src/state.rs | 192 +----- src/app/mod.rs | 37 + src/smoke_tests.rs | 1 + src/views/panels/hook_panel.rs | 645 ++++++++++++++++++ src/views/panels/mod.rs | 1 + src/views/panels/project_column.rs | 47 ++ src/views/root/handlers.rs | 7 + src/views/root/render.rs | 10 + 17 files changed, 827 insertions(+), 186 deletions(-) create mode 100644 src/views/panels/hook_panel.rs diff --git a/crates/okena-views-sidebar/src/hook_list.rs b/crates/okena-views-sidebar/src/hook_list.rs index 04e0caff..2b995e9d 100644 --- a/crates/okena-views-sidebar/src/hook_list.rs +++ b/crates/okena-views-sidebar/src/hook_list.rs @@ -76,8 +76,14 @@ impl Sidebar { let terminal_id = terminal_id.clone(); move |this, _, _window, cx| { this.cursor_index = None; - this.workspace.update(cx, |ws, cx| { - ws.focus_terminal_by_id(&project_id, &terminal_id, cx); + this.request_broker.update(cx, |broker, cx| { + broker.push_overlay_request( + okena_workspace::requests::OverlayRequest::ShowHookTerminal { + project_id: project_id.clone(), + terminal_id: terminal_id.clone(), + }, + cx, + ); }); } })) diff --git a/crates/okena-views-sidebar/src/sidebar.rs b/crates/okena-views-sidebar/src/sidebar.rs index 426e4c12..d77b59d1 100644 --- a/crates/okena-views-sidebar/src/sidebar.rs +++ b/crates/okena-views-sidebar/src/sidebar.rs @@ -139,9 +139,6 @@ pub struct Sidebar { pub service_manager: Option>, /// Collapsed state for group headers (Terminals/Services) per project pub(crate) collapsed_groups: HashSet<(String, GroupKind)>, - /// Project IDs that have been auto-expanded due to hook terminals. - /// Tracked so we only auto-expand once (user can collapse afterward). - pub(crate) hook_auto_expanded: HashSet, /// Parent project IDs with in-flight worktree creation (debounce guard) pub(crate) creating_worktree: HashSet, /// Callback to get settings @@ -170,27 +167,8 @@ impl Sidebar { cx.notify(); }).detach(); - // Auto-expand projects that gain hook terminals (outside of render). - // Tracked in hook_auto_expanded so we only expand once per project - // (user can collapse afterward without it re-expanding). - cx.observe(&workspace, |this, workspace, cx| { - let ws = workspace.read(cx); - let mut changed = false; - for project in &ws.data().projects { - // Auto-expand projects with hook terminals - if !project.hook_terminals.is_empty() && this.hook_auto_expanded.insert(project.id.clone()) { - this.expanded_projects.insert(project.id.clone()); - changed = true; - } - } - let before_len = this.hook_auto_expanded.len(); - this.hook_auto_expanded.retain(|id| { - ws.data().projects.iter().any(|p| p.id == *id && !p.hook_terminals.is_empty()) - }); - if changed || this.hook_auto_expanded.len() != before_len { - cx.notify(); - } - }).detach(); + // Hook terminals are displayed in the dedicated HookPanel, so we no + // longer auto-expand the sidebar project when hooks appear. Self { workspace, @@ -213,7 +191,6 @@ impl Sidebar { dispatch_action: None, service_manager: None, collapsed_groups: HashSet::new(), - hook_auto_expanded: HashSet::new(), creating_worktree: HashSet::new(), get_settings: None, get_remote_connections: None, @@ -455,7 +432,12 @@ impl Sidebar { } pub(crate) fn is_group_collapsed(&self, project_id: &str, group: &GroupKind) -> bool { - self.collapsed_groups.contains(&(project_id.to_string(), group.clone())) + let key = (project_id.to_string(), group.clone()); + match group { + // Hooks are collapsed by default — only open if explicitly expanded + GroupKind::Hooks => !self.collapsed_groups.contains(&key), + _ => self.collapsed_groups.contains(&key), + } } /// Render expanded children (terminals group + services group) for a project. diff --git a/crates/okena-views-terminal/src/layout/split_pane.rs b/crates/okena-views-terminal/src/layout/split_pane.rs index b8309d72..7bbe4b90 100644 --- a/crates/okena-views-terminal/src/layout/split_pane.rs +++ b/crates/okena-views-terminal/src/layout/split_pane.rs @@ -40,6 +40,12 @@ pub enum DragState { initial_mouse_y: f32, initial_height: f32, }, + /// Resizing per-project hook panel height + HookPanel { + project_id: String, + initial_mouse_y: f32, + initial_height: f32, + }, } /// Trait object wrapper for ActionDispatch in DragState (needs Clone). @@ -166,7 +172,7 @@ pub fn compute_resize( ws.update_project_widths(new_widths, cx); }); } - DragState::Sidebar | DragState::ServicePanel { .. } => { + DragState::Sidebar | DragState::ServicePanel { .. } | DragState::HookPanel { .. } => { // Handled directly in RootView's on_mouse_move } } diff --git a/crates/okena-workspace/src/actions/folder.rs b/crates/okena-workspace/src/actions/folder.rs index 6f7faba1..f3a856d4 100644 --- a/crates/okena-workspace/src/actions/folder.rs +++ b/crates/okena-workspace/src/actions/folder.rs @@ -176,6 +176,7 @@ mod tests { project_order: order.into_iter().map(String::from).collect(), project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], } } @@ -290,6 +291,7 @@ mod gpui_tests { project_order: order.into_iter().map(String::from).collect(), project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], } } diff --git a/crates/okena-workspace/src/actions/layout.rs b/crates/okena-workspace/src/actions/layout.rs index 1c1e032a..48443c7b 100644 --- a/crates/okena-workspace/src/actions/layout.rs +++ b/crates/okena-workspace/src/actions/layout.rs @@ -1414,6 +1414,7 @@ mod gpui_tests { project_order: order.into_iter().map(String::from).collect(), project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], } } diff --git a/crates/okena-workspace/src/actions/project.rs b/crates/okena-workspace/src/actions/project.rs index 57e899cd..d913e0a8 100644 --- a/crates/okena-workspace/src/actions/project.rs +++ b/crates/okena-workspace/src/actions/project.rs @@ -337,6 +337,12 @@ impl Workspace { self.notify_data(cx); } + /// Update hook panel height for a project + pub fn update_hook_panel_height(&mut self, project_id: &str, height: f32, cx: &mut Context) { + self.data.hook_panel_heights.insert(project_id.to_string(), height); + self.notify_data(cx); + } + /// Get project width or default equal distribution pub fn get_project_width(&self, project_id: &str, visible_count: usize) -> f32 { self.data.project_widths @@ -735,6 +741,7 @@ mod tests { project_order: vec![], project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], } } @@ -829,6 +836,7 @@ mod gpui_tests { project_order: vec![], project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], } } diff --git a/crates/okena-workspace/src/hooks.rs b/crates/okena-workspace/src/hooks.rs index 9717e4cf..6ebd58e8 100644 --- a/crates/okena-workspace/src/hooks.rs +++ b/crates/okena-workspace/src/hooks.rs @@ -125,7 +125,10 @@ impl HookRunner { script.push_str(&format!("export {}='{}'; ", k, escaped_v)); } script.push_str(command); - script.push_str("; exec \"${SHELL:-sh}\""); + // Capture exit code and report it via OSC title before exec-ing + // into the interactive shell. The PTY event loop detects titles + // matching __okena_hook_exit: and updates hook status. + script.push_str("; __okena_rc=$?; printf '\\033]0;__okena_hook_exit:%d\\007' \"$__okena_rc\"; exec \"${SHELL:-sh}\""); let shell = ShellType::for_command(script); self.backend.create_terminal(cwd, Some(&shell)) } else { diff --git a/crates/okena-workspace/src/persistence.rs b/crates/okena-workspace/src/persistence.rs index a547ecc8..b881ada3 100644 --- a/crates/okena-workspace/src/persistence.rs +++ b/crates/okena-workspace/src/persistence.rs @@ -464,6 +464,7 @@ pub fn default_workspace() -> WorkspaceData { project_order: vec![project_id], project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: Vec::new(), } } @@ -504,6 +505,7 @@ mod tests { project_order: order.into_iter().map(String::from).collect(), project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders, } } @@ -686,6 +688,7 @@ mod tests { project_order: vec![], project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], }; let migrated = migrate_workspace(data); @@ -700,6 +703,7 @@ mod tests { project_order: vec![], project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], }; let migrated = migrate_workspace(data); diff --git a/crates/okena-workspace/src/requests.rs b/crates/okena-workspace/src/requests.rs index 9a5acbba..c8e7df9a 100644 --- a/crates/okena-workspace/src/requests.rs +++ b/crates/okena-workspace/src/requests.rs @@ -53,6 +53,7 @@ pub enum OverlayRequest { position: gpui::Point, }, ShowServiceLog { project_id: String, service_name: String }, + ShowHookTerminal { project_id: String, terminal_id: String }, FileSearch { project_path: String }, ContentSearch { project_path: String }, FileBrowser { project_path: String }, diff --git a/crates/okena-workspace/src/state.rs b/crates/okena-workspace/src/state.rs index 7dd3470f..ba182920 100644 --- a/crates/okena-workspace/src/state.rs +++ b/crates/okena-workspace/src/state.rs @@ -44,6 +44,9 @@ pub struct WorkspaceData { /// Service panel heights in pixels (project_id -> height) #[serde(default)] pub service_panel_heights: HashMap, + /// Hook panel heights in pixels (project_id -> height) + #[serde(default)] + pub hook_panel_heights: HashMap, } impl WorkspaceData { @@ -71,6 +74,9 @@ impl WorkspaceData { service_panel_heights: self.service_panel_heights.iter() .filter(|(id, _)| !remote_ids.contains(id.as_str())) .map(|(k, v)| (k.clone(), *v)).collect(), + hook_panel_heights: self.hook_panel_heights.iter() + .filter(|(id, _)| !remote_ids.contains(id.as_str())) + .map(|(k, v)| (k.clone(), *v)).collect(), folders: self.folders.iter() .filter(|f| !f.id.starts_with("remote-folder:")) .cloned().collect(), @@ -424,63 +430,8 @@ impl Workspace { let label = entry.label.clone(); project.hook_terminals.insert(terminal_id.to_string(), entry); - // Add the hook terminal to the project's layout so it renders in a pane. - // If there are already hook terminals, group them in Tabs to avoid nested splits - // that progressively shrink the main terminal area. - let hook_node = LayoutNode::Terminal { - terminal_id: Some(terminal_id.to_string()), - minimized: false, - detached: false, - shell_type: okena_terminal::shell_config::ShellType::Default, - zoom_level: 1.0, - }; - if let Some(ref mut existing) = project.layout { - // Check if the layout is already a horizontal split with hook terminals - // on the right side — if so, add to existing Tabs group. - let added_to_existing = if let LayoutNode::Split { - direction: SplitDirection::Horizontal, - children, .. - } = existing { - if children.len() == 2 { - match &mut children[1] { - LayoutNode::Tabs { children: tab_children, active_tab } => { - // Already a Tabs group — append and switch to new tab - tab_children.push(hook_node.clone()); - *active_tab = tab_children.len() - 1; - true - } - other @ LayoutNode::Terminal { .. } => { - // Single hook terminal — convert to Tabs for the new one. - // Check layout structure directly instead of relying on - // hook_terminals count which can be stale after removals. - let prev = other.clone(); - *other = LayoutNode::Tabs { - children: vec![prev, hook_node.clone()], - active_tab: 1, - }; - true - } - _ => false, - } - } else { - false - } - } else { - false - }; - - if !added_to_existing { - let existing = existing.clone(); - project.layout = Some(LayoutNode::Split { - direction: SplitDirection::Horizontal, - children: vec![existing, hook_node], - sizes: vec![0.7, 0.3], - }); - } - } else { - project.layout = Some(hook_node); - } - // Set the terminal name to the hook label + // Hook terminals are displayed in the dedicated HookPanel (not in the layout tree). + // Set the terminal name so the panel can display it. project.terminal_names.insert(terminal_id.to_string(), label); self.notify_data(cx); @@ -2686,6 +2637,7 @@ mod workspace_tests { project_order: order.into_iter().map(String::from).collect(), project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: Vec::new(), } } @@ -3474,6 +3426,7 @@ mod gpui_tests { project_order: order.into_iter().map(String::from).collect(), project_widths: HashMap::new(), service_panel_heights: HashMap::new(), + hook_panel_heights: HashMap::new(), folders: vec![], } } @@ -3710,71 +3663,37 @@ mod gpui_tests { ws.register_hook_terminal("p1", "hook-1", make_hook_entry("on_project_open"), cx); }); + // Hook terminals are stored in hook_terminals, NOT in the layout tree workspace.read_with(cx, |ws: &Workspace, _cx| { - let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - assert!(matches!(layout, LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "hook-1")); - }); - } - - #[gpui::test] - fn test_register_hook_terminal_creates_split(cx: &mut gpui::TestAppContext) { - let data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); - let workspace = cx.new(|_cx| Workspace::new(data)); - - workspace.update(cx, |ws: &mut Workspace, cx| { - ws.register_hook_terminal("p1", "hook-1", make_hook_entry("on_project_open"), cx); - }); - - workspace.read_with(cx, |ws: &Workspace, _cx| { - let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - match layout { - LayoutNode::Split { direction, children, sizes } => { - assert_eq!(*direction, SplitDirection::Horizontal); - assert_eq!(children.len(), 2); - assert!((sizes[0] - 0.7).abs() < 0.01); - assert!((sizes[1] - 0.3).abs() < 0.01); - // Original terminal on left - assert!(matches!(&children[0], LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "term_p1")); - // Hook terminal on right - assert!(matches!(&children[1], LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "hook-1")); - } - _ => panic!("Expected horizontal split, got {:?}", layout), - } + let p = ws.project("p1").unwrap(); + assert!(p.layout.is_none()); // Layout unchanged + assert!(p.hook_terminals.contains_key("hook-1")); + assert!(p.terminal_names.contains_key("hook-1")); }); } #[gpui::test] - fn test_register_second_hook_creates_tabs(cx: &mut gpui::TestAppContext) { + fn test_register_hook_terminal_does_not_modify_layout(cx: &mut gpui::TestAppContext) { let data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { ws.register_hook_terminal("p1", "hook-1", make_hook_entry("on_project_open"), cx); - ws.register_hook_terminal("p1", "hook-2", make_hook_entry("pre_merge"), cx); }); + // Layout should remain a single terminal (unchanged) workspace.read_with(cx, |ws: &Workspace, _cx| { - let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - match layout { - LayoutNode::Split { children, .. } => { - assert_eq!(children.len(), 2); - match &children[1] { - LayoutNode::Tabs { children: tabs, active_tab } => { - assert_eq!(tabs.len(), 2); - assert_eq!(*active_tab, 1); - assert!(matches!(&tabs[0], LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "hook-1")); - assert!(matches!(&tabs[1], LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "hook-2")); - } - _ => panic!("Expected Tabs node for second hook"), - } - } - _ => panic!("Expected split layout"), - } + let p = ws.project("p1").unwrap(); + let layout = p.layout.as_ref().unwrap(); + // Original terminal still there, no split created + assert!(matches!(layout, LayoutNode::Terminal { terminal_id: Some(id), .. } if id == "term_p1")); + // Hook is in hook_terminals + assert!(p.hook_terminals.contains_key("hook-1")); }); } #[gpui::test] - fn test_register_third_hook_appends_to_tabs(cx: &mut gpui::TestAppContext) { + fn test_register_multiple_hooks_stored_in_hashmap(cx: &mut gpui::TestAppContext) { let data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); @@ -3785,36 +3704,26 @@ mod gpui_tests { }); workspace.read_with(cx, |ws: &Workspace, _cx| { - let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - match layout { - LayoutNode::Split { children, .. } => { - match &children[1] { - LayoutNode::Tabs { children: tabs, active_tab } => { - assert_eq!(tabs.len(), 3); - assert_eq!(*active_tab, 2); - } - _ => panic!("Expected Tabs"), - } - } - _ => panic!("Expected split layout"), - } + let p = ws.project("p1").unwrap(); + assert_eq!(p.hook_terminals.len(), 3); + assert!(p.hook_terminals.contains_key("hook-1")); + assert!(p.hook_terminals.contains_key("hook-2")); + assert!(p.hook_terminals.contains_key("hook-3")); + // Layout should be unchanged (single terminal) + assert!(matches!(p.layout.as_ref().unwrap(), LayoutNode::Terminal { .. })); }); } #[gpui::test] - fn test_remove_hook_terminal_cleans_layout(cx: &mut gpui::TestAppContext) { - let mut p = make_project("p1"); - p.layout = None; - let data = make_workspace_data(vec![p], vec!["p1"]); + fn test_remove_hook_terminal_cleans_hashmap(cx: &mut gpui::TestAppContext) { + let data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); let workspace = cx.new(|_cx| Workspace::new(data)); workspace.update(cx, |ws: &mut Workspace, cx| { ws.register_hook_terminal("p1", "hook-1", make_hook_entry("on_project_open"), cx); }); - // Verify terminal is there workspace.read_with(cx, |ws: &Workspace, _cx| { - assert!(ws.project("p1").unwrap().layout.is_some()); assert!(ws.project("p1").unwrap().hook_terminals.contains_key("hook-1")); }); @@ -3824,7 +3733,6 @@ mod gpui_tests { workspace.read_with(cx, |ws: &Workspace, _cx| { let p = ws.project("p1").unwrap(); - assert!(p.layout.is_none()); assert!(p.hook_terminals.is_empty()); assert!(!p.terminal_names.contains_key("hook-1")); }); @@ -3873,37 +3781,9 @@ mod gpui_tests { let entry = project.hook_terminals.get("hook-1-new").unwrap(); assert_eq!(entry.status, HookTerminalStatus::Running); assert_eq!(entry.hook_type, "on_project_open"); - // Layout updated - assert!(project.layout.as_ref().unwrap().find_terminal_path("hook-1").is_none()); - assert!(project.layout.as_ref().unwrap().find_terminal_path("hook-1-new").is_some()); - }); - } - - #[gpui::test] - fn test_replace_terminal_id_in_layout(cx: &mut gpui::TestAppContext) { - let data = make_workspace_data(vec![make_project("p1")], vec!["p1"]); - let workspace = cx.new(|_cx| Workspace::new(data)); - - // Register two hook terminals to get them into layout as tabs - workspace.update(cx, |ws: &mut Workspace, cx| { - ws.register_hook_terminal("p1", "hook-1", make_hook_entry("on_project_open"), cx); - ws.register_hook_terminal("p1", "hook-2", make_hook_entry("pre_merge"), cx); - }); - - // Replace hook-1 with hook-1-new in the layout tree directly - workspace.update(cx, |ws: &mut Workspace, cx| { - if let Some(ref mut layout) = ws.data.projects.iter_mut().find(|p| p.id == "p1").unwrap().layout { - layout.replace_terminal_id("hook-1", "hook-1-new"); - } - cx.notify(); - }); - - workspace.read_with(cx, |ws: &Workspace, _cx| { - let layout = ws.project("p1").unwrap().layout.as_ref().unwrap(); - assert!(layout.find_terminal_path("hook-1").is_none()); - assert!(layout.find_terminal_path("hook-1-new").is_some()); - // Other terminals unaffected - assert!(layout.find_terminal_path("hook-2").is_some()); + // Terminal name updated + assert!(!project.terminal_names.contains_key("hook-1")); + assert!(project.terminal_names.contains_key("hook-1-new")); }); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1829c2f7..2bf41913 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -577,6 +577,43 @@ impl Okena { } } + // Check if any hook terminal reported its exit code via + // OSC title (__okena_hook_exit:). This happens when + // keep_alive hooks finish their command but the PTY stays + // alive as an interactive shell. + if !dirty_terminal_ids.is_empty() { + let terminals_guard = this.terminals.lock(); + let ws = this.workspace.read(cx); + let mut status_updates: Vec<(String, crate::workspace::state::HookTerminalStatus)> = Vec::new(); + for tid in &dirty_terminal_ids { + if ws.is_hook_terminal(tid).is_none() { + continue; + } + if let Some(terminal) = terminals_guard.get(tid) { + if let Some(title) = terminal.title() { + if let Some(code_str) = title.strip_prefix("__okena_hook_exit:") { + let exit_code = code_str.parse::().unwrap_or(-1); + let status = if exit_code == 0 { + crate::workspace::state::HookTerminalStatus::Succeeded + } else { + crate::workspace::state::HookTerminalStatus::Failed { exit_code } + }; + status_updates.push((tid.clone(), status)); + } + } + } + } + drop(terminals_guard); + drop(ws); + if !status_updates.is_empty() { + this.workspace.update(cx, |ws, cx| { + for (tid, status) in status_updates { + ws.update_hook_terminal_status(&tid, status, cx); + } + }); + } + } + if !exit_events.is_empty() { this.root_view.update(cx, |_, cx| cx.notify()); } diff --git a/src/smoke_tests.rs b/src/smoke_tests.rs index e1f9a6b5..a8e73f5b 100644 --- a/src/smoke_tests.rs +++ b/src/smoke_tests.rs @@ -108,6 +108,7 @@ mod tests { project_widths: Default::default(), folders: vec![], service_panel_heights: Default::default(), + hook_panel_heights: Default::default(), }) }); } diff --git a/src/views/panels/hook_panel.rs b/src/views/panels/hook_panel.rs new file mode 100644 index 00000000..a63cfc31 --- /dev/null +++ b/src/views/panels/hook_panel.rs @@ -0,0 +1,645 @@ +//! HookPanel — per-project hook terminal panel, similar to ServicePanel. +//! +//! Displays hook terminal outputs in a toggleable panel below the main +//! terminal layout. Each hook terminal gets a tab; clicking a tab shows +//! its output in a TerminalPane. + +use crate::action_dispatch::ActionDispatcher; +use crate::terminal::backend::TerminalBackend; +use crate::theme::ThemeColors; +use crate::ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; +use crate::views::root::TerminalsRegistry; +use crate::workspace::request_broker::RequestBroker; +use crate::workspace::state::{HookTerminalEntry, HookTerminalStatus, Workspace}; + +use gpui::prelude::*; +use gpui::*; +use gpui_component::tooltip::Tooltip; +use okena_ui::icon_button::icon_button_sized; +use okena_views_terminal::elements::resize_handle::ResizeHandle; +use okena_views_terminal::layout::split_pane::{ActiveDrag, DragState}; +use okena_views_terminal::layout::terminal_pane::TerminalPane; + +use std::sync::Arc; + +/// Per-project hook terminal panel entity. +pub struct HookPanel { + project_id: String, + workspace: Entity, + request_broker: Entity, + backend: Arc, + terminals: TerminalsRegistry, + active_drag: ActiveDrag, + + /// Whether the hook panel is open. + panel_open: bool, + /// Currently active hook terminal ID. + active_terminal_id: Option, + /// Terminal pane showing the active hook's output. + terminal_pane: Option>>, + /// Height of the hook panel in pixels. + panel_height: f32, + /// Number of hook terminals last observed (for auto-open on new hooks). + last_hook_count: usize, +} + +impl HookPanel { + pub fn new( + project_id: String, + workspace: Entity, + request_broker: Entity, + backend: Arc, + terminals: TerminalsRegistry, + active_drag: ActiveDrag, + initial_height: f32, + cx: &mut Context, + ) -> Self { + let initial_count = workspace.read(cx).project(&project_id) + .map(|p| p.hook_terminals.len()) + .unwrap_or(0); + + // Observe workspace — auto-open when new hook terminals appear, + // auto-close when all hooks finish. + cx.observe(&workspace, |this: &mut Self, ws, cx| { + let project = ws.read(cx).project(&this.project_id); + let current_count = project.map(|p| p.hook_terminals.len()).unwrap_or(0); + + if current_count > this.last_hook_count { + // New hook terminal(s) appeared — auto-open with the latest one + if let Some(newest_tid) = project + .and_then(|p| { + p.hook_terminals.keys() + .find(|k| !this.active_terminal_id.as_ref().is_some_and(|a| a == *k)) + .or_else(|| p.hook_terminals.keys().last()) + .cloned() + }) + { + this.show_hook(&newest_tid, cx); + } + } else if this.panel_open && current_count > 0 { + // Auto-close when all hooks succeeded (stay open on failures) + let all_succeeded = project + .map(|p| p.hook_terminals.values() + .all(|e| e.status == HookTerminalStatus::Succeeded)) + .unwrap_or(false); + if all_succeeded { + this.close(cx); + } + } + + this.last_hook_count = current_count; + cx.notify(); + }).detach(); + + Self { + project_id, + workspace, + request_broker, + backend, + terminals, + active_drag, + panel_open: false, + active_terminal_id: None, + terminal_pane: None, + panel_height: initial_height, + last_hook_count: initial_count, + } + } + + /// Whether the hook panel is currently open. + #[allow(dead_code)] + pub fn is_open(&self) -> bool { + self.panel_open + } + + /// Show a specific hook terminal in the panel. + pub fn show_hook(&mut self, terminal_id: &str, cx: &mut Context) { + self.active_terminal_id = Some(terminal_id.to_string()); + self.panel_open = true; + + let project_path = self.workspace.read(cx).project(&self.project_id) + .map(|p| p.path.clone()) + .unwrap_or_default(); + + let ws = self.workspace.clone(); + let rb = self.request_broker.clone(); + let backend = self.backend.clone(); + let terminals = self.terminals.clone(); + let pid = self.project_id.clone(); + let tid = terminal_id.to_string(); + + let pane = cx.new(move |cx| { + TerminalPane::new( + ws, + rb, + pid, + project_path, + vec![usize::MAX], + Some(tid), + false, + false, + backend, + terminals, + None, + cx, + ) + }); + + self.terminal_pane = Some(pane); + cx.notify(); + } + + /// Toggle the panel open with the first hook, or close if already open. + pub fn toggle(&mut self, cx: &mut Context) { + if self.panel_open { + self.close(cx); + } else { + // Open with the first hook terminal, or just open empty + let first_tid = self.workspace.read(cx).project(&self.project_id) + .and_then(|p| p.hook_terminals.keys().next().cloned()); + if let Some(tid) = first_tid { + self.show_hook(&tid, cx); + } else { + self.panel_open = true; + cx.notify(); + } + } + } + + /// Close the hook panel. + pub fn close(&mut self, cx: &mut Context) { + self.panel_open = false; + self.terminal_pane = None; + self.active_terminal_id = None; + cx.notify(); + } + + /// Set the panel height (called during drag resize). + pub fn set_panel_height(&mut self, height: f32, cx: &mut Context) { + self.panel_height = height.clamp(80.0, 600.0); + let project_id = self.project_id.clone(); + let h = self.panel_height; + self.workspace.update(cx, |ws, cx| { + ws.update_hook_panel_height(&project_id, h, cx); + }); + cx.notify(); + } + + /// Get the hook terminals for this project. + fn get_hook_list(&self, cx: &Context) -> Vec<(String, HookTerminalEntry)> { + self.workspace.read(cx).project(&self.project_id) + .map(|p| { + p.hook_terminals.iter() + .map(|(id, entry)| (id.clone(), entry.clone())) + .collect() + }) + .unwrap_or_default() + } + + /// Rerun a hook by killing the old PTY and creating a new one. + fn rerun_hook(&mut self, terminal_id: &str, command: &str, cwd: &str, cx: &mut Context) { + let Some(runner) = cx.try_global::().cloned() else { + log::warn!("Cannot rerun hook: no HookRunner available"); + return; + }; + + // Kill old PTY + runner.backend.kill(terminal_id); + + match runner.backend.create_terminal(cwd, None) { + Ok(new_terminal_id) => { + let transport = runner.backend.transport(); + let terminal = Arc::new(okena_terminal::terminal::Terminal::new( + new_terminal_id.clone(), + okena_terminal::terminal::TerminalSize::default(), + transport.clone(), + cwd.to_string(), + )); + + // Replace in TerminalsRegistry + { + let mut guard = self.terminals.lock(); + guard.remove(terminal_id); + guard.insert(new_terminal_id.clone(), terminal); + } + + // Swap terminal ID in workspace + self.workspace.update(cx, |ws, cx| { + ws.swap_hook_terminal_id(&self.project_id, terminal_id, &new_terminal_id, cx); + }); + + // Type the command into the new shell + let cmd_with_newline = format!("{}\n", command); + transport.send_input(&new_terminal_id, cmd_with_newline.as_bytes()); + + // Switch the panel to the new terminal + self.show_hook(&new_terminal_id, cx); + + log::info!("Hook rerun: replaced {} with {}", terminal_id, new_terminal_id); + } + Err(e) => { + log::error!("Failed to rerun hook terminal: {}", e); + } + } + } + + /// Dismiss a hook terminal. + fn dismiss_hook(&mut self, terminal_id: &str, cx: &mut Context) { + if let Some(monitor) = cx.try_global::() { + monitor.notify_exit(terminal_id, None); + } + self.workspace.update(cx, |ws, cx| { + ws.cancel_pending_worktree_close(terminal_id); + ws.remove_hook_terminal(terminal_id, cx); + }); + self.terminals.lock().remove(terminal_id); + + // If we just dismissed the active terminal, switch to another or close + if self.active_terminal_id.as_deref() == Some(terminal_id) { + let next = self.workspace.read(cx).project(&self.project_id) + .and_then(|p| p.hook_terminals.keys().next().cloned()); + if let Some(next_tid) = next { + self.show_hook(&next_tid, cx); + } else { + self.close(cx); + } + } + } + + /// Dismiss all hooks that are not currently running. + fn dismiss_finished_hooks(&mut self, cx: &mut Context) { + let finished: Vec = self.get_hook_list(cx) + .into_iter() + .filter(|(_, e)| e.status != HookTerminalStatus::Running) + .map(|(id, _)| id) + .collect(); + for tid in finished { + self.dismiss_hook(&tid, cx); + } + } + + // ── Rendering ─────────────────────────────────────────────────── + + /// Render the hook indicator button for the project header. + pub fn render_hook_indicator(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + let hooks = self.get_hook_list(cx); + + if hooks.is_empty() { + return div().into_any_element(); + } + + // Compute aggregate status color + let has_failed = hooks.iter().any(|(_, e)| matches!(e.status, HookTerminalStatus::Failed { .. })); + let has_running = hooks.iter().any(|(_, e)| e.status == HookTerminalStatus::Running); + let all_succeeded = hooks.iter().all(|(_, e)| e.status == HookTerminalStatus::Succeeded); + + let dot_color = if has_failed { + t.term_red + } else if has_running { + t.term_yellow + } else if all_succeeded { + t.success + } else { + t.text_muted + }; + + let tooltip_text = format!("{} hook{}", hooks.len(), if hooks.len() == 1 { "" } else { "s" }); + let entity = cx.entity().downgrade(); + + div() + .id("hook-indicator-btn") + .cursor_pointer() + .w(px(24.0)) + .h(px(24.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.0)) + .hover(|s| s.bg(rgb(t.bg_hover))) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_click(move |_, _window, cx| { + cx.stop_propagation(); + if let Some(e) = entity.upgrade() { + e.update(cx, |this, cx| { + this.toggle(cx); + }); + } + }) + .child( + svg() + .path("icons/terminal.svg") + .size(px(12.0)) + .text_color(rgb(dot_color)), + ) + .tooltip(move |_window, cx| Tooltip::new(tooltip_text.clone()).build(_window, cx)) + .into_any_element() + } + + /// Render the hook panel (resize handle + tab header + terminal pane). + pub fn render_panel(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + if !self.panel_open { + return div().into_any_element(); + } + let hooks = self.get_hook_list(cx); + + if hooks.is_empty() { + return div().into_any_element(); + } + + let active_tid = self.active_terminal_id.clone(); + let project_id = self.project_id.clone(); + let active_drag = self.active_drag.clone(); + let panel_height = self.panel_height; + + div() + .id("hook-panel") + .flex() + .flex_col() + .h(px(panel_height)) + .flex_shrink_0() + // Resize handle + .child( + ResizeHandle::new( + true, + t.border, + t.border_active, + move |mouse_pos, _cx| { + *active_drag.borrow_mut() = Some(DragState::HookPanel { + project_id: project_id.clone(), + initial_mouse_y: f32::from(mouse_pos.y), + initial_height: panel_height, + }); + }, + ), + ) + // Tab header + .child(self.render_header(&hooks, active_tid.as_deref(), t, cx)) + // Content + .child( + if self.terminal_pane.is_some() { + div() + .flex_1() + .min_h_0() + .min_w_0() + .overflow_hidden() + .children(self.terminal_pane.clone()) + .into_any_element() + } else { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child("Select a hook to view its output") + .into_any_element() + }, + ) + .into_any_element() + } + + /// Render the tab header row. + fn render_header( + &self, + hooks: &[(String, HookTerminalEntry)], + active_tid: Option<&str>, + t: &ThemeColors, + cx: &mut Context, + ) -> impl IntoElement { + let entity = cx.entity().downgrade(); + + div() + .id("hook-panel-header") + .flex_shrink_0() + .bg(rgb(t.bg_header)) + .border_b_1() + .border_color(rgb(t.border)) + .flex() + .items_center() + .child( + // Tabs area + div() + .id("hook-tabs-scroll") + .flex_1() + .min_w_0() + .flex() + .overflow_x_scroll() + .children( + hooks.iter().map(|(tid, entry)| { + let is_active = active_tid == Some(tid.as_str()); + let status_color = match &entry.status { + HookTerminalStatus::Running => t.term_yellow, + HookTerminalStatus::Succeeded => t.success, + HookTerminalStatus::Failed { .. } => t.error, + }; + + let tid_for_click = tid.clone(); + let entity_for_click = entity.clone(); + + div() + .id(ElementId::Name(format!("hook-tab-{}", tid).into())) + .cursor_pointer() + .h(px(34.0)) + .px(px(12.0)) + .flex() + .items_center() + .flex_shrink_0() + .gap(px(6.0)) + .text_size(ui_text_md(cx)) + .when(is_active, |d| { + d.bg(rgb(t.bg_primary)) + .text_color(rgb(t.text_primary)) + }) + .when(!is_active, |d| { + d.text_color(rgb(t.text_secondary)) + .hover(|s| s.bg(rgb(t.bg_hover))) + }) + .child( + div() + .flex_shrink_0() + .w(px(7.0)) + .h(px(7.0)) + .rounded(px(3.5)) + .bg(rgb(status_color)), + ) + .child(entry.label.clone()) + .on_click(move |_, _window, cx| { + if let Some(e) = entity_for_click.upgrade() { + e.update(cx, |this, cx| { + this.show_hook(&tid_for_click, cx); + }); + } + }) + }), + ), + ) + // "Clear finished" link (when 2+ hooks are done) + .when( + hooks.iter().filter(|(_, e)| e.status != HookTerminalStatus::Running).count() >= 2, + |d| { + let entity_clear = entity.clone(); + d.child( + div() + .id("hook-clear-finished") + .cursor_pointer() + .flex_shrink_0() + .h(px(34.0)) + .px(px(8.0)) + .flex() + .items_center() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .hover(|s| s.text_color(rgb(t.text_secondary))) + .child("Clear finished") + .on_click(move |_, _window, cx| { + cx.stop_propagation(); + if let Some(e) = entity_clear.upgrade() { + e.update(cx, |this, cx| { + this.dismiss_finished_hooks(cx); + }); + } + }), + ) + }, + ) + // Action buttons for active hook + .child({ + let active_entry = active_tid.and_then(|tid| { + hooks.iter().find(|(id, _)| id == tid).map(|(id, e)| (id.clone(), e.clone())) + }); + + let mut actions = div() + .flex() + .flex_shrink_0() + .h(px(34.0)) + .items_center() + .gap(px(2.0)) + .mr(px(4.0)) + .border_l_1() + .border_color(rgb(t.border)) + .pl(px(6.0)); + + if let Some((tid, entry)) = active_entry { + let is_running = entry.status == HookTerminalStatus::Running; + + // Exit code badge (before action buttons) + if let HookTerminalStatus::Failed { exit_code } = &entry.status { + actions = actions.child( + div() + .px(px(5.0)) + .py(px(1.0)) + .rounded(px(3.0)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.term_red)) + .child(format!("exit {}", exit_code)), + ); + } + + // Rerun button (always visible, dimmed when running) + let entity_rerun = entity.clone(); + let tid_rerun = tid.clone(); + let command = entry.command.clone(); + let cwd = entry.cwd.clone(); + let mut rerun_btn = icon_button_sized( + "hook-panel-rerun", "icons/refresh.svg", 22.0, 12.0, t, + ); + if is_running { + rerun_btn = rerun_btn + .opacity(0.3) + .cursor_default(); + } else { + rerun_btn = rerun_btn + .on_click(move |_, _window, cx| { + cx.stop_propagation(); + if let Some(e) = entity_rerun.upgrade() { + e.update(cx, |this, cx| { + this.rerun_hook(&tid_rerun, &command, &cwd, cx); + }); + } + }); + } + actions = actions.child( + rerun_btn.tooltip(move |_window, cx| { + Tooltip::new(if is_running { "Running\u{2026}" } else { "Rerun Hook" }) + .build(_window, cx) + }), + ); + + // Dismiss button (trash icon, red) + let entity_dismiss = entity.clone(); + let tid_dismiss = tid.clone(); + actions = actions.child( + div() + .id("hook-panel-dismiss") + .flex_shrink_0() + .cursor_pointer() + .w(px(22.0)) + .h(px(22.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(3.0)) + .hover(|s| s.bg(rgba(0xf14c4c33))) + .child( + svg() + .path("icons/trash.svg") + .size(px(12.0)) + .text_color(rgb(t.term_red)), + ) + .on_click(move |_, _window, cx| { + cx.stop_propagation(); + if let Some(e) = entity_dismiss.upgrade() { + e.update(cx, |this, cx| { + this.dismiss_hook(&tid_dismiss, cx); + }); + } + }) + .tooltip(|_window, cx| { + Tooltip::new("Dismiss Hook").build(_window, cx) + }), + ); + } + + actions + }) + // Hide panel button (chevron-down, non-destructive) + .child( + div() + .flex_shrink_0() + .h(px(34.0)) + .flex() + .items_center() + .child({ + let entity_close = entity.clone(); + div() + .id("hook-panel-hide") + .cursor_pointer() + .w(px(26.0)) + .h(px(26.0)) + .mx(px(4.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(3.0)) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child( + svg() + .path("icons/chevron-down.svg") + .size(px(14.0)) + .text_color(rgb(t.text_secondary)), + ) + .on_click(move |_, _window, cx| { + if let Some(e) = entity_close.upgrade() { + e.update(cx, |this, cx| this.close(cx)); + } + }) + .tooltip(|_window, cx| { + Tooltip::new("Hide Panel").build(_window, cx) + }) + }), + ) + } +} diff --git a/src/views/panels/mod.rs b/src/views/panels/mod.rs index bb6b0201..2dfd7898 100644 --- a/src/views/panels/mod.rs +++ b/src/views/panels/mod.rs @@ -5,6 +5,7 @@ //! - Project columns for multi-project workspace //! - Status bar at the bottom +pub mod hook_panel; pub mod project_column; pub mod sidebar; pub mod status_bar; diff --git a/src/views/panels/project_column.rs b/src/views/panels/project_column.rs index efcaf21f..c1f1e1eb 100644 --- a/src/views/panels/project_column.rs +++ b/src/views/panels/project_column.rs @@ -19,6 +19,7 @@ use std::sync::Arc; use okena_core::api::ActionRequest; use okena_workspace::requests::OverlayRequest; use okena_views_services::service_panel::ServicePanel; +use crate::views::panels::hook_panel::HookPanel; use crate::views::root::TerminalsRegistry; /// A single project column with header and layout @@ -42,6 +43,8 @@ pub struct ProjectColumn { git_header: Entity, /// Self-contained service panel entity service_panel: Entity>, + /// Self-contained hook panel entity + hook_panel: Entity, } impl ProjectColumn { @@ -85,6 +88,22 @@ impl ProjectColumn { // Observe service_panel so ProjectColumn re-renders when panel state changes cx.observe(&service_panel, |_, _, cx| cx.notify()).detach(); + let initial_hook_height = workspace.read(cx).data.hook_panel_heights + .get(&project_id).copied().unwrap_or(200.0); + + let hook_panel = { + let pid = project_id.clone(); + let ws = workspace.clone(); + let rb = request_broker.clone(); + let be = backend.clone(); + let ts = terminals.clone(); + let ad = active_drag.clone(); + cx.new(move |cx| { + HookPanel::new(pid, ws, rb, be, ts, ad, initial_hook_height, cx) + }) + }; + cx.observe(&hook_panel, |_, _, cx| cx.notify()).detach(); + Self { workspace, request_broker, @@ -97,6 +116,7 @@ impl ProjectColumn { action_dispatcher: None, git_header, service_panel, + hook_panel, } } @@ -154,6 +174,21 @@ impl ProjectColumn { }); } + /// Show a hook terminal in the hook panel. + pub fn show_hook_terminal(&mut self, terminal_id: &str, cx: &mut Context) { + let tid = terminal_id.to_string(); + self.hook_panel.update(cx, |hp, cx| { + hp.show_hook(&tid, cx); + }); + } + + /// Set the hook panel height (called during drag resize). + pub fn set_hook_panel_height(&mut self, height: f32, cx: &mut Context) { + self.hook_panel.update(cx, |hp, cx| { + hp.set_panel_height(height, cx); + }); + } + /// Observe workspace for remote service state changes (used for remote project columns). pub fn observe_remote_services(&mut self, workspace: Entity, cx: &mut Context) { // Sync dispatcher to service panel (may have been set before panel was created) @@ -486,6 +521,12 @@ impl ProjectColumn { .tooltip(|_window, cx| Tooltip::new("Focus Project").build(_window, cx)), ), ) + // Hook indicator (delegated to HookPanel entity) + .child({ + self.hook_panel.update(cx, |hp, cx| { + hp.render_hook_indicator(&t, cx) + }) + }) // Service indicator (delegated to ServicePanel entity) .child({ self.service_panel.update(cx, |sp, cx| { @@ -659,6 +700,12 @@ impl Render for ProjectColumn { .bg(bg_color) .child(self.render_header(&project, cx)) .child(content) + // Hook panel (delegated to HookPanel entity) + .child({ + self.hook_panel.update(cx, |hp, cx| { + hp.render_panel(&t, cx) + }) + }) // Service panel (delegated to ServicePanel entity) .child({ self.service_panel.update(cx, |sp, cx| { diff --git a/src/views/root/handlers.rs b/src/views/root/handlers.rs index 9146a93f..bf13e85d 100644 --- a/src/views/root/handlers.rs +++ b/src/views/root/handlers.rs @@ -386,6 +386,13 @@ impl RootView { OverlayRequest::ShowServiceLog { project_id, service_name } => { self.handle_show_service_log(project_id, service_name, cx); } + OverlayRequest::ShowHookTerminal { project_id, terminal_id } => { + if let Some(col) = self.project_columns.get(&project_id).cloned() { + col.update(cx, |col, cx| { + col.show_hook_terminal(&terminal_id, cx); + }); + } + } OverlayRequest::FileSearch { project_path } => { self.overlay_manager.update(cx, |om, cx| { om.toggle_file_search(std::path::PathBuf::from(project_path), cx); diff --git a/src/views/root/render.rs b/src/views/root/render.rs index cd65b709..7e3db1ac 100644 --- a/src/views/root/render.rs +++ b/src/views/root/render.rs @@ -446,6 +446,16 @@ impl Render for RootView { }); } } + DragState::HookPanel { project_id, initial_mouse_y, initial_height } => { + let delta = initial_mouse_y - f32::from(event.position.y); + let new_height = initial_height + delta; + let project_id = project_id.clone(); + if let Some(col) = this.project_columns.get(&project_id).cloned() { + col.update(cx, |col, cx| { + col.set_hook_panel_height(new_height, cx); + }); + } + } _ => { // Handle split and project column resize compute_resize(event.position, state, &workspace, cx); From 10ab54d13581e709c6b0d30c8c3fa5c95eeaf27d Mon Sep 17 00:00:00 2001 From: Jakub Date: Tue, 31 Mar 2026 16:24:41 +0200 Subject: [PATCH 2/2] fix: default hooks group to open in sidebar Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/okena-views-sidebar/src/sidebar.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/okena-views-sidebar/src/sidebar.rs b/crates/okena-views-sidebar/src/sidebar.rs index d77b59d1..5f75263c 100644 --- a/crates/okena-views-sidebar/src/sidebar.rs +++ b/crates/okena-views-sidebar/src/sidebar.rs @@ -432,12 +432,7 @@ impl Sidebar { } pub(crate) fn is_group_collapsed(&self, project_id: &str, group: &GroupKind) -> bool { - let key = (project_id.to_string(), group.clone()); - match group { - // Hooks are collapsed by default — only open if explicitly expanded - GroupKind::Hooks => !self.collapsed_groups.contains(&key), - _ => self.collapsed_groups.contains(&key), - } + self.collapsed_groups.contains(&(project_id.to_string(), group.clone())) } /// Render expanded children (terminals group + services group) for a project.