diff --git a/.gitignore b/.gitignore index ea10126e..5c9e629e 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,5 @@ coverage/ # Git worktrees .worktrees/ worktrees/ +vibehq-hub/ +.docx \ No newline at end of file diff --git a/crates/codirigent-core/src/persistence.rs b/crates/codirigent-core/src/persistence.rs index 34592bdd..5e7c5813 100644 --- a/crates/codirigent-core/src/persistence.rs +++ b/crates/codirigent-core/src/persistence.rs @@ -45,6 +45,9 @@ pub struct PersistentSession { pub status: SessionStatus, /// Working directory. pub working_directory: PathBuf, + /// Requested shell for this session. `None` means Auto. + #[serde(default)] + pub shell: Option, /// Current task if any. pub current_task: Option, /// Git worktree path if using worktrees. @@ -108,6 +111,7 @@ impl PersistentSession { name: session.name.clone(), status: session.status, working_directory: session.working_directory.clone(), + shell: session.shell.clone(), current_task: session.current_task.clone(), worktree_path: None, context_usage: session.context_usage, @@ -183,6 +187,7 @@ impl PersistentSession { name: self.name.clone(), status: SessionStatus::Idle, // Reset status on restore working_directory: self.working_directory.clone(), + shell: self.shell.clone(), current_task: self.current_task.clone(), context_usage: None, // Reset on restore created_at: self.started_at, @@ -569,6 +574,7 @@ mod tests { #[test] fn test_persistent_session_roundtrip() { let mut session = Session::new(SessionId(1), "Test".to_string(), PathBuf::from("/tmp")); + session.shell = Some("bash".to_string()); session.group = Some("backend".to_string()); session.color = Some("#FF0000".to_string()); @@ -578,6 +584,7 @@ mod tests { assert_eq!(restored.id, session.id); assert_eq!(restored.name, session.name); assert_eq!(restored.working_directory, session.working_directory); + assert_eq!(restored.shell, Some("bash".to_string())); assert_eq!(restored.group, session.group); assert_eq!(restored.color, session.color); // Status should be reset to Idle diff --git a/crates/codirigent-core/src/types/layout.rs b/crates/codirigent-core/src/types/layout.rs index 62435c31..94f4d61a 100644 --- a/crates/codirigent-core/src/types/layout.rs +++ b/crates/codirigent-core/src/types/layout.rs @@ -280,6 +280,56 @@ impl LayoutNode { } } + /// Adjust the split ratio for the divider between two child subtrees. + /// + /// Finds the split node whose first subtree contains `first_slot` and whose + /// second subtree contains `second_slot`, then updates that split's ratio. + /// Returns `None` if no such split exists. + pub fn set_ratio_for_divider( + &self, + first_slot: SlotId, + second_slot: SlotId, + new_ratio: f32, + ) -> Option { + let clamped = new_ratio.clamp(0.1, 0.9); + match self { + LayoutNode::Leaf { .. } => None, + LayoutNode::Split { + direction, + ratio, + first, + second, + } => { + if first.contains_slot(first_slot) && second.contains_slot(second_slot) { + Some(LayoutNode::Split { + direction: *direction, + ratio: clamped, + first: first.clone(), + second: second.clone(), + }) + } else if let Some(new_first) = + first.set_ratio_for_divider(first_slot, second_slot, new_ratio) + { + Some(LayoutNode::Split { + direction: *direction, + ratio: *ratio, + first: Box::new(new_first), + second: second.clone(), + }) + } else { + second + .set_ratio_for_divider(first_slot, second_slot, new_ratio) + .map(|new_second| LayoutNode::Split { + direction: *direction, + ratio: *ratio, + first: first.clone(), + second: Box::new(new_second), + }) + } + } + } + } + fn direct_child_has_slot(&self, child: &LayoutNode, target: SlotId) -> bool { matches!(child, LayoutNode::Leaf { slot } if *slot == target) } diff --git a/crates/codirigent-core/src/types/mod.rs b/crates/codirigent-core/src/types/mod.rs index b151ce0a..9f91fd50 100644 --- a/crates/codirigent-core/src/types/mod.rs +++ b/crates/codirigent-core/src/types/mod.rs @@ -24,7 +24,7 @@ pub use git::{GitChangeKind, GitChangedFile, GitRepoInfo}; pub use ids::{SessionId, TaskId}; pub use layout::{GridPosition, LayoutMode, LayoutNode, SlotId, SplitDirection}; pub use session::{CodexExecutionMode, Session}; -pub use state::{AppState, QueueState, WindowState}; +pub use state::{AppState, PaneId, PaneStackState, PaneTabGroup, QueueState, WindowState}; pub use status::{ContextThresholdState, SessionStatus, ShellState, TaskPriority, TaskStatus}; pub use task::{RetryConfig, Task, VerificationConfig}; pub use verification::{TestFailure, TestResults, VerificationResult}; diff --git a/crates/codirigent-core/src/types/session.rs b/crates/codirigent-core/src/types/session.rs index e06f9e86..6b03c054 100644 --- a/crates/codirigent-core/src/types/session.rs +++ b/crates/codirigent-core/src/types/session.rs @@ -34,6 +34,9 @@ pub struct Session { pub status: SessionStatus, /// Working directory for this session. pub working_directory: PathBuf, + /// Requested shell for this session. `None` means Auto. + #[serde(default)] + pub shell: Option, /// Currently assigned task, if any. pub current_task: Option, /// Context window usage (0.0 - 1.0), if available. @@ -73,6 +76,7 @@ impl Session { name, status: SessionStatus::default(), working_directory, + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), diff --git a/crates/codirigent-core/src/types/state.rs b/crates/codirigent-core/src/types/state.rs index 0630b4d9..936e33cc 100644 --- a/crates/codirigent-core/src/types/state.rs +++ b/crates/codirigent-core/src/types/state.rs @@ -3,10 +3,45 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::ids::TaskId; -use super::layout::LayoutMode; +use super::ids::{SessionId, TaskId}; +use super::layout::{LayoutMode, SlotId}; use super::session::Session; +/// Persistent identifier for a visible pane. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PaneId { + /// Grid-layout pane by stable cell index. + GridCell { + /// Zero-based grid cell index in row-major order. + index: usize, + }, + /// Split-tree pane by slot identifier. + SplitSlot { + /// Stable split-tree slot identifier. + slot: SlotId, + }, +} + +/// Persistent tab state for a visible pane. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaneTabGroup { + /// Pane that owns the tab stack. + pub pane: PaneId, + /// Ordered session IDs in the tab strip. + pub session_ids: Vec, + /// Active session currently rendered in the pane. + pub active_session_id: SessionId, +} + +/// Persisted ordered pane stack state, including hidden stacks. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PaneStackState { + /// Ordered session IDs in the stack. + pub session_ids: Vec, + /// Active session currently rendered when the stack is visible. + pub active_session_id: SessionId, +} + /// Persisted window position and size. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WindowState { @@ -29,6 +64,12 @@ pub struct AppState { pub sessions: Vec, /// Current layout mode. pub layout: LayoutMode, + /// Persisted per-pane tab stacks. + #[serde(default)] + pub pane_tab_groups: Vec, + /// Persisted pane stacks in workspace order, including hidden stacks. + #[serde(default)] + pub pane_stacks: Vec, /// Last updated timestamp. pub updated_at: Option>, /// Saved window position and size. diff --git a/crates/codirigent-core/tests/persistence_tests.rs b/crates/codirigent-core/tests/persistence_tests.rs index 16ce258f..bb925ba4 100644 --- a/crates/codirigent-core/tests/persistence_tests.rs +++ b/crates/codirigent-core/tests/persistence_tests.rs @@ -20,6 +20,7 @@ fn test_save_and_load_state() { name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -90,6 +91,7 @@ fn test_overwrite_state() { name: "Session 1".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -115,6 +117,7 @@ fn test_overwrite_state() { name: "Session 2".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -256,6 +259,7 @@ fn test_multiple_checkpoints_independent() { name: "State 1".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -278,6 +282,7 @@ fn test_multiple_checkpoints_independent() { name: "State 2".to_string(), status: SessionStatus::Idle, working_directory: temp.path().to_path_buf(), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -370,6 +375,7 @@ fn test_session_to_persistent_conversion() { name: "Test".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: Some(0.5), created_at: chrono::Utc::now(), diff --git a/crates/codirigent-session/src/manager.rs b/crates/codirigent-session/src/manager.rs index 672b2ad6..84746da7 100644 --- a/crates/codirigent-session/src/manager.rs +++ b/crates/codirigent-session/src/manager.rs @@ -538,6 +538,7 @@ impl SessionManager for DefaultSessionManager { // Create session metadata let mut session = Session::new(id, name, working_dir.clone()); + session.shell = shell.filter(|value| !value.is_empty()); // Detect git info for the working directory session.git_info = self diff --git a/crates/codirigent-ui/src/integration.rs b/crates/codirigent-ui/src/integration.rs index e3c0c9b5..1f679f03 100644 --- a/crates/codirigent-ui/src/integration.rs +++ b/crates/codirigent-ui/src/integration.rs @@ -355,6 +355,8 @@ impl CodirigentIntegration { let state = AppState { sessions, layout: codirigent_core::LayoutMode::default(), + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: Some(chrono::Utc::now()), window_bounds: None, }; @@ -562,6 +564,8 @@ impl CodirigentIntegration { let state = AppState { sessions, layout: codirigent_core::LayoutMode::default(), + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: Some(chrono::Utc::now()), window_bounds: None, }; diff --git a/crates/codirigent-ui/src/layout/split.rs b/crates/codirigent-ui/src/layout/split.rs index 67c7be59..b45c97fe 100644 --- a/crates/codirigent-ui/src/layout/split.rs +++ b/crates/codirigent-ui/src/layout/split.rs @@ -435,6 +435,29 @@ mod tests { assert!(layout.divider_at_point(Point::new(100.0, 400.0)).is_none()); } + #[test] + fn test_split_layout_divider_at_point_finds_nested_parent_divider() { + let root = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }; + let layout = SplitLayout::new(root, Bounds::from_size(1000.0, 800.0), 4.0); + + let divider = layout + .divider_at_point(Point::new(499.0, 400.0)) + .expect("expected nested parent divider"); + assert_eq!(divider.first_slot, SlotId(0)); + assert_eq!(divider.second_slot, SlotId(2)); + assert_eq!(divider.direction, SplitDirection::Horizontal); + } + #[test] fn test_split_layout_asymmetric() { // 2 stacked on left + 1 full-height on right diff --git a/crates/codirigent-ui/src/layout/state.rs b/crates/codirigent-ui/src/layout/state.rs index ddaf0f56..07fd9355 100644 --- a/crates/codirigent-ui/src/layout/state.rs +++ b/crates/codirigent-ui/src/layout/state.rs @@ -29,8 +29,10 @@ pub enum FocusDirection { pub struct LayoutState { /// Current layout profile. profile: LayoutProfile, - /// Session assignments to grid positions. - assignments: Vec, + /// Session assignments to visible grid positions. + assignments: Vec>, + /// Sessions that exist but are not currently visible in a grid cell. + overflow: Vec, /// Currently focused session index. focused_index: Option, } @@ -46,7 +48,8 @@ impl LayoutState { pub fn new() -> Self { Self { profile: LayoutProfile::default(), - assignments: Vec::new(), + assignments: vec![None; LayoutProfile::default().max_sessions()], + overflow: Vec::new(), focused_index: None, } } @@ -55,7 +58,8 @@ impl LayoutState { pub fn with_profile(profile: LayoutProfile) -> Self { Self { profile, - assignments: Vec::new(), + assignments: vec![None; profile.max_sessions()], + overflow: Vec::new(), focused_index: None, } } @@ -67,7 +71,15 @@ impl LayoutState { /// Set the layout profile. pub fn set_profile(&mut self, profile: LayoutProfile) { + if self.profile == profile { + return; + } + + let ordered = self.ordered_sessions(); self.profile = profile; + self.assignments = vec![None; profile.max_sessions()]; + self.overflow.clear(); + self.set_assignments(ordered); } /// Cycle to the next layout profile. @@ -81,13 +93,44 @@ impl LayoutState { } /// Get the session assignments. - pub fn assignments(&self) -> &[SessionId] { + pub fn assignments(&self) -> &[Option] { &self.assignments } + /// Get the overflow sessions. + pub fn overflow(&self) -> &[SessionId] { + &self.overflow + } + + /// Return all sessions in workspace order: visible cells first, then overflow. + pub fn ordered_sessions(&self) -> Vec { + self.assignments + .iter() + .flatten() + .copied() + .chain(self.overflow.iter().copied()) + .collect() + } + /// Set the session assignments. pub fn set_assignments(&mut self, assignments: Vec) { - self.assignments = assignments; + self.assignments.fill(None); + self.overflow.clear(); + + for session_id in assignments { + if let Some(cell) = self.assignments.iter_mut().find(|cell| cell.is_none()) { + *cell = Some(session_id); + } else { + self.overflow.push(session_id); + } + } + + if self + .focused_index + .is_some_and(|index| self.session_at(index).is_none()) + { + self.focused_index = self.first_occupied_index(); + } } /// Add a session to the layout. @@ -98,12 +141,41 @@ impl LayoutState { /// /// `true` if the session was added, `false` if the layout is full. pub fn add_session(&mut self, session_id: SessionId) -> bool { - if self.assignments.len() < self.profile.max_sessions() { - self.assignments.push(session_id); + if self + .assignments + .iter() + .flatten() + .any(|&id| id == session_id) + || self.overflow.contains(&session_id) + { + return false; + } + + if let Some(cell) = self.assignments.iter_mut().find(|cell| cell.is_none()) { + *cell = Some(session_id); true } else { - false + self.overflow.push(session_id); + true + } + } + + /// Append a hidden session after the visible grid assignments. + /// + /// Hidden sessions stay in assignment order so they can be swapped back + /// into a visible cell later without losing workspace ordering. + pub fn append_hidden_session(&mut self, session_id: SessionId) -> bool { + if self + .assignments + .iter() + .flatten() + .any(|&id| id == session_id) + || self.overflow.contains(&session_id) + { + return false; } + self.overflow.push(session_id); + true } /// Remove a session from the layout. @@ -112,17 +184,21 @@ impl LayoutState { /// /// `true` if the session was removed, `false` if not found. pub fn remove_session(&mut self, session_id: SessionId) -> bool { - if let Some(pos) = self.assignments.iter().position(|&id| id == session_id) { - self.assignments.remove(pos); - // Adjust focused index if needed + if let Some(pos) = self + .assignments + .iter() + .position(|cell| *cell == Some(session_id)) + { + self.assignments[pos] = None; if let Some(focused) = self.focused_index { if focused == pos { - self.focused_index = self.assignments.first().map(|_| 0); - } else if focused > pos { - self.focused_index = Some(focused - 1); + self.focused_index = self.first_occupied_index(); } } true + } else if let Some(pos) = self.overflow.iter().position(|&id| id == session_id) { + self.overflow.remove(pos); + true } else { false } @@ -130,7 +206,7 @@ impl LayoutState { /// Get the session at a given index. pub fn session_at(&self, index: usize) -> Option { - self.assignments.get(index).copied() + self.assignments.get(index).and_then(|session| *session) } /// Get the session at a given grid position. @@ -155,7 +231,7 @@ impl LayoutState { /// Set the focused session by index. pub fn focus_index(&mut self, index: usize) { - if index < self.assignments.len() { + if self.session_at(index).is_some() { self.focused_index = Some(index); } } @@ -166,7 +242,11 @@ impl LayoutState { /// /// `true` if the session was found and focused. pub fn focus_session(&mut self, session_id: SessionId) -> bool { - if let Some(index) = self.assignments.iter().position(|&id| id == session_id) { + if let Some(index) = self + .assignments + .iter() + .position(|cell| *cell == Some(session_id)) + { self.focused_index = Some(index); true } else { @@ -176,27 +256,35 @@ impl LayoutState { /// Focus the next session in the layout. pub fn focus_next(&mut self) { - if self.assignments.is_empty() { + let occupied = self.occupied_indices(); + if occupied.is_empty() { return; } - let next = match self.focused_index { - Some(i) => (i + 1) % self.assignments.len(), + let next = match self + .focused_index + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { + Some(i) => (i + 1) % occupied.len(), None => 0, }; - self.focused_index = Some(next); + self.focused_index = Some(occupied[next]); } /// Focus the previous session in the layout. pub fn focus_previous(&mut self) { - if self.assignments.is_empty() { + let occupied = self.occupied_indices(); + if occupied.is_empty() { return; } - let prev = match self.focused_index { + let prev = match self + .focused_index + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { Some(i) if i > 0 => i - 1, - Some(_) => self.assignments.len() - 1, + Some(_) => occupied.len() - 1, None => 0, }; - self.focused_index = Some(prev); + self.focused_index = Some(occupied[prev]); } /// Focus the session in the given direction from current focus. @@ -206,8 +294,8 @@ impl LayoutState { /// * `direction` - Direction to move focus (Up, Down, Left, Right) pub fn focus_direction(&mut self, direction: FocusDirection) { let Some(current_index) = self.focused_index else { - if !self.assignments.is_empty() { - self.focused_index = Some(0); + if let Some(index) = self.first_occupied_index() { + self.focused_index = Some(index); } return; }; @@ -248,7 +336,7 @@ impl LayoutState { }; let new_index = (new_row * cols + new_col) as usize; - if new_index < self.assignments.len() { + if self.session_at(new_index).is_some() { self.focused_index = Some(new_index); } } @@ -260,7 +348,12 @@ impl LayoutState { /// /// Returns `true` if both indices are valid and different. pub fn swap_assignments(&mut self, a: usize, b: usize) -> bool { - if a == b || a >= self.assignments.len() || b >= self.assignments.len() { + if a == b + || a >= self.assignments.len() + || b >= self.assignments.len() + || self.assignments[a].is_none() + || self.assignments[b].is_none() + { return false; } self.assignments.swap(a, b); @@ -274,6 +367,114 @@ impl LayoutState { } true } + + /// Get the index of a hidden session in overflow order. + pub fn overflow_index_of(&self, session_id: SessionId) -> Option { + self.overflow.iter().position(|&id| id == session_id) + } + + /// Replace the session in a visible cell with a hidden overflow session. + /// + /// Returns the previous visible session when the swap succeeds. + pub fn swap_hidden_into_index( + &mut self, + hidden_session_id: SessionId, + index: usize, + ) -> Option { + let hidden_index = self.overflow_index_of(hidden_session_id)?; + let previous = self + .assignments + .get_mut(index)? + .replace(hidden_session_id)?; + self.overflow[hidden_index] = previous; + self.focused_index = Some(index); + Some(previous) + } + + /// Place a session into a specific grid cell. + pub fn assign_session_to_index(&mut self, session_id: SessionId, index: usize) -> bool { + if index >= self.assignments.len() { + return false; + } + + if self + .assignments + .iter() + .flatten() + .any(|&id| id == session_id) + { + return false; + } + + if let Some(hidden_index) = self.overflow_index_of(session_id) { + self.overflow.remove(hidden_index); + } + + if let Some(previous) = self.assignments[index].replace(session_id) { + self.overflow.insert(0, previous); + } + + true + } + + /// Replace the session in a visible cell without moving the previous one + /// into overflow. Used for pane-local tab activation where both sessions + /// remain members of the same pane stack. + pub fn replace_session_in_index( + &mut self, + index: usize, + session_id: SessionId, + ) -> Option> { + if index >= self.assignments.len() { + return None; + } + + if self + .assignments + .iter() + .enumerate() + .any(|(current_index, cell)| current_index != index && *cell == Some(session_id)) + { + return None; + } + + if let Some(hidden_index) = self.overflow_index_of(session_id) { + self.overflow.remove(hidden_index); + } + + Some(self.assignments[index].replace(session_id)) + } + + /// Remove a session from a visible cell without promoting overflow. + pub fn clear_index(&mut self, index: usize) -> Option { + if index >= self.assignments.len() { + return None; + } + + let removed = self.assignments[index].take(); + if self.focused_index == Some(index) && removed.is_some() { + self.focused_index = self.first_occupied_index(); + } + removed + } + + /// Get the first visible empty cell. + pub fn first_empty_index(&self) -> Option { + self.assignments.iter().position(|cell| cell.is_none()) + } + + /// Return all occupied cell indices in order. + pub fn occupied_indices(&self) -> Vec { + self.assignments + .iter() + .enumerate() + .filter_map(|(index, session)| session.map(|_| index)) + .collect() + } + + fn first_occupied_index(&self) -> Option { + self.occupied_indices().into_iter().next() + } } /// Split layout state manager. @@ -399,6 +600,27 @@ impl SplitLayoutState { false } + /// Replace the session assigned to a slot. + /// + /// Returns the previous session in the slot, if any. + pub fn replace_session_in_slot( + &mut self, + slot: SlotId, + session_id: SessionId, + ) -> Option> { + if self.assignments.iter().any(|(_, s)| *s == Some(session_id)) { + return None; + } + + let entry = self + .assignments + .iter_mut() + .find(|(current, _)| *current == slot)?; + let previous = entry.1.replace(session_id); + self.focused_slot = Some(slot); + Some(previous) + } + /// Remove a session from its slot. pub fn remove_session(&mut self, session_id: SessionId) -> bool { for entry in &mut self.assignments { @@ -593,6 +815,24 @@ impl SplitLayoutState { } } + /// Resize the split identified by a visible divider between two subtrees. + pub fn resize_divider( + &mut self, + first_slot: SlotId, + second_slot: SlotId, + new_ratio: f32, + ) -> bool { + if let Some(new_tree) = self + .tree + .set_ratio_for_divider(first_slot, second_slot, new_ratio) + { + self.tree = new_tree; + true + } else { + false + } + } + /// Get the number of leaf slots. pub fn slot_count(&self) -> usize { self.tree.leaf_count() @@ -754,7 +994,7 @@ impl WorkspaceLayoutState { /// Get all assigned session IDs in order. pub fn assigned_sessions(&self) -> Vec { match self { - WorkspaceLayoutState::Grid(s) => s.assignments().to_vec(), + WorkspaceLayoutState::Grid(s) => s.ordered_sessions(), WorkspaceLayoutState::SplitTree(s) => s.assigned_sessions(), } } @@ -770,7 +1010,7 @@ mod tests { fn test_layout_state_new() { let state = LayoutState::new(); assert_eq!(state.profile(), LayoutProfile::Grid2x2); - assert!(state.assignments().is_empty()); + assert_eq!(state.assignments(), &[None, None, None, None]); assert!(state.focused_index().is_none()); } @@ -803,9 +1043,10 @@ mod tests { assert!(state.add_session(SessionId(2))); assert!(state.add_session(SessionId(3))); assert!(state.add_session(SessionId(4))); - assert!(!state.add_session(SessionId(5))); // Full + assert!(state.add_session(SessionId(5))); // Overflow hidden session assert_eq!(state.assignments().len(), 4); + assert_eq!(state.overflow(), &[SessionId(5)]); } #[test] @@ -816,8 +1057,10 @@ mod tests { state.focus_index(1); assert!(state.remove_session(SessionId(1))); - assert_eq!(state.assignments().len(), 1); - assert_eq!(state.focused_index(), Some(0)); // Adjusted + assert_eq!(state.assignments().len(), 4); + assert_eq!(state.session_at(0), None); + assert_eq!(state.session_at(1), Some(SessionId(2))); + assert_eq!(state.focused_index(), Some(1)); // Stable grid index assert!(!state.remove_session(SessionId(99))); // Not found } @@ -1136,6 +1379,60 @@ mod tests { assert!(state.resize_split(SlotId(0), 0.3)); } + #[test] + fn test_split_layout_state_resize_divider_updates_nested_parent_split() { + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }; + let mut state = SplitLayoutState::new(tree); + + assert!(state.resize_divider(SlotId(0), SlotId(2), 0.75)); + assert_eq!( + state.tree(), + &LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.75, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + } + ); + } + + #[test] + fn test_split_layout_state_resize_divider_clamps_ratio() { + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }; + let mut state = SplitLayoutState::new(tree); + + assert!(state.resize_divider(SlotId(0), SlotId(1), 0.01)); + assert_eq!( + state.tree(), + &LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.1, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + } + ); + } + #[test] fn test_split_layout_state_assigned_sessions() { let mut state = SplitLayoutState::from_grid(2, 2); diff --git a/crates/codirigent-ui/src/sidebar/tests.rs b/crates/codirigent-ui/src/sidebar/tests.rs index b64813f2..a6e96049 100644 --- a/crates/codirigent-ui/src/sidebar/tests.rs +++ b/crates/codirigent-ui/src/sidebar/tests.rs @@ -9,6 +9,7 @@ fn create_test_session(id: u64, name: &str, status: SessionStatus) -> Session { name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -35,6 +36,7 @@ fn create_grouped_session( name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -458,6 +460,7 @@ fn create_session_with_context( name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage, created_at: chrono::Utc::now(), @@ -479,6 +482,7 @@ fn create_session_with_task(id: u64, name: &str, status: SessionStatus, task: &s name: name.to_string(), status, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: Some(TaskId::from(task)), context_usage: None, created_at: chrono::Utc::now(), diff --git a/crates/codirigent-ui/src/terminal_header.rs b/crates/codirigent-ui/src/terminal_header.rs index 44cee926..23f7cb9c 100644 --- a/crates/codirigent-ui/src/terminal_header.rs +++ b/crates/codirigent-ui/src/terminal_header.rs @@ -27,6 +27,10 @@ pub struct TerminalHeader { pub project_name: Option, /// CLI engine name (e.g., "Claude", "Gemini 2.0"). pub cli_name: Option, + /// Effective shell label shown in the header (e.g. "Auto", "bash"). + pub shell_label: Option, + /// Warning shown when the requested shell could not be honored on restore. + pub shell_warning: Option, /// Whether the session needs user attention. pub needs_attention: bool, /// AI-generated summary of current activity. @@ -50,6 +54,8 @@ impl Default for TerminalHeader { is_focused: false, project_name: None, cli_name: None, + shell_label: None, + shell_warning: None, needs_attention: false, ai_summary: None, git_branch: None, @@ -105,6 +111,13 @@ impl TerminalHeader { self } + /// Set the shell label and optional warning. + pub fn with_shell(mut self, label: impl Into, warning: Option) -> Self { + self.shell_label = Some(label.into()); + self.shell_warning = warning; + self + } + /// Set the AI summary. pub fn with_ai_summary(mut self, summary: impl Into) -> Self { self.ai_summary = Some(summary.into()); @@ -327,6 +340,10 @@ pub struct TerminalHeaderRenderHints { pub project_name: Option, /// CLI engine name. pub cli_name: Option, + /// Effective shell label shown in the header. + pub shell_label: Option, + /// Warning shown when the requested shell could not be honored on restore. + pub shell_warning: Option, /// Whether the session needs user attention. pub needs_attention: bool, /// AI-generated summary. @@ -355,6 +372,8 @@ impl TerminalHeader { height: Self::DEFAULT_HEIGHT, project_name: self.project_name.clone(), cli_name: self.cli_name.clone(), + shell_label: self.shell_label.clone(), + shell_warning: self.shell_warning.clone(), needs_attention: self.needs_attention, ai_summary: self.ai_summary.clone(), git_branch: self.git_branch.clone(), @@ -577,6 +596,23 @@ mod tests { assert_eq!(header.cli_name, Some("Gemini 2.0".to_string())); } + #[test] + fn test_terminal_header_shell_label() { + let header = TerminalHeader::new("S1", SessionStatus::Working) + .with_shell("bash", Some("Requested shell unavailable".to_string())); + assert_eq!(header.shell_label, Some("bash".to_string())); + assert_eq!( + header.shell_warning, + Some("Requested shell unavailable".to_string()) + ); + let hints = header.render_hints(); + assert_eq!(hints.shell_label, Some("bash".to_string())); + assert_eq!( + hints.shell_warning, + Some("Requested shell unavailable".to_string()) + ); + } + #[test] fn test_terminal_header_ai_summary() { let header = TerminalHeader::new("S1", SessionStatus::Working) diff --git a/crates/codirigent-ui/src/workspace/README.md b/crates/codirigent-ui/src/workspace/README.md index 268fe4ff..1c6ab140 100644 --- a/crates/codirigent-ui/src/workspace/README.md +++ b/crates/codirigent-ui/src/workspace/README.md @@ -1,16 +1,21 @@ # Workspace Module -The workspace module owns the main application window: session layout, GPUI view state, rendering, polling, and workspace-scoped UI interactions. +The workspace module owns the main application window: session layout, GPUI +view state, rendering, polling, and workspace-scoped UI interactions. + +For the longer-form architecture reference, see +[`docs/architecture/workspace/`](../../../../docs/architecture/workspace/). ## Current Structure - `core.rs` - Canonical workspace state and layout logic. - - Session placement, focus, bounds, and layout transitions. + - Session placement, focus, bounds, pane stacks, and layout transitions. - `gpui.rs` - Root `WorkspaceView` type. - - Constructor wiring, trait impls, render entry point, keyboard/IME handling, and high-level orchestration. + - Constructor wiring, trait impls, render entry point, keyboard/IME handling, + and root event wiring. - Lower-coupling helper clusters live under `workspace/gpui/`: - `session_metadata.rs` - `derived_state.rs` @@ -27,17 +32,40 @@ The workspace module owns the main application window: session layout, GPUI view - `git_refresh.rs` - `terminal_input.rs` -- Rendering modules - - `render.rs`, `grid_render.rs`, `drawer_render.rs`, `task_board_render.rs`, `top_bar_render.rs`, `icon_rail_render.rs`, `modal_render.rs` - - These keep UI composition close to the components they render while relying on root-owned `WorkspaceView` state. +## Render-Facing Modules + +- `render.rs` + - Main workspace composition. + +- `grid_render.rs` + - Grid-layout composition, split/grid dispatch, and shared session-cell + rendering. + +- `split_render.rs` + - Recursive split-tree rendering, divider setup, and empty split-slot + rendering. + +- `pane_header_render.rs` + - Pane tabs, header badges, and pane-local session creation affordances. + +- `impl_pointer_interactions.rs` + - Workspace-global drag/resize reducers used by the GPUI root. + +- `drawer_render.rs`, `task_board_render.rs`, `top_bar_render.rs`, + `icon_rail_render.rs`, `modal_render.rs`, `terminal_render.rs` + - Focused render helpers for their respective UI regions. ## Dependency Shape - `Workspace` in `core.rs` stays free of GPUI concerns. -- `WorkspaceView` in `gpui.rs` is the GPUI-facing root and remains the main place to start reading the UI layer. -- `workspace/gpui/*.rs` helpers extend `WorkspaceView` without changing public module paths. -- `workspace/impl_output_polling/*.rs` helpers extend the polling root without changing public module paths. -- Sibling modules coordinate through `WorkspaceView` methods rather than importing one another's private helpers. +- `WorkspaceView` in `gpui.rs` is the GPUI-facing root and remains the main + place to start reading the UI layer. +- `workspace/gpui/*.rs` helpers extend `WorkspaceView` without changing public + module paths. +- `workspace/impl_output_polling/*.rs` helpers extend the polling root without + changing public module paths. +- Sibling modules coordinate through `WorkspaceView` methods rather than + importing one another's private helpers. ## Key Responsibilities @@ -54,6 +82,13 @@ The workspace module owns the main application window: session layout, GPUI view - Session metadata helpers: - `workspace/gpui/session_metadata.rs` +- Split rendering and divider behavior: + - `split_render.rs` + - `impl_pointer_interactions.rs` + +- Pane tabs, badges, and pane `+` behavior: + - `pane_header_render.rs` + - Output polling and runtime preparation: - `impl_output_polling.rs` - `workspace/impl_output_polling/output_runtime.rs` @@ -74,6 +109,3 @@ The workspace layer is verified with: ```bash cargo test -p codirigent-ui --lib workspace:: ``` - -For durable architecture docs and lookup guidance, use -`docs/architecture/workspace/`. diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index 427fc52a..e00c6ba5 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -31,8 +31,17 @@ use crate::layout::{ SplitLayoutState, WorkspaceLayoutState, TOP_BAR_HEIGHT, }; use crate::theme::CodirigentTheme; -use codirigent_core::{LayoutNode, Session, SessionId, SessionStatus, SlotId, SplitDirection}; -use std::collections::HashSet; +use codirigent_core::{ + LayoutNode, PaneId, PaneStackState, PaneTabGroup, Session, SessionId, SessionStatus, SlotId, + SplitDirection, +}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone)] +struct PaneStack { + session_ids: Vec, + active_session_id: SessionId, +} /// Main workspace containing the grid of sessions. /// @@ -45,6 +54,10 @@ use std::collections::HashSet; pub struct Workspace { /// Unified layout state supporting both grid and split tree modes. layout_state: WorkspaceLayoutState, + /// Pane-local tab stacks keyed by visible pane identifier. + pane_tab_groups: HashMap, + /// Ordered tab stacks that no longer fit in the current visible layout. + hidden_pane_stacks: Vec, /// Sessions in the workspace. sessions: Vec, /// Theme configuration. @@ -70,6 +83,8 @@ impl Workspace { pub fn new() -> Self { Self { layout_state: WorkspaceLayoutState::default(), + pane_tab_groups: HashMap::new(), + hidden_pane_stacks: Vec::new(), sessions: Vec::new(), theme: CodirigentTheme::default(), show_sidebar: true, @@ -83,6 +98,8 @@ impl Workspace { pub fn with_profile(profile: LayoutProfile) -> Self { Self { layout_state: WorkspaceLayoutState::with_profile(profile), + pane_tab_groups: HashMap::new(), + hidden_pane_stacks: Vec::new(), sessions: Vec::new(), theme: CodirigentTheme::default(), show_sidebar: true, @@ -110,7 +127,9 @@ impl Workspace { /// When switching from split tree to grid, sessions are re-assigned /// in their current order to the new grid. pub fn set_layout(&mut self, profile: LayoutProfile) { - self.layout_state = WorkspaceLayoutState::Grid(self.rebuild_grid_state(profile)); + let stacks = self.current_pane_stacks_in_order(); + self.layout_state = WorkspaceLayoutState::Grid(self.rebuild_grid_state(profile, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } /// Cycle to the next layout profile. @@ -168,47 +187,419 @@ impl Workspace { &self.layout_state } - /// Return all workspace sessions in the current pane order, with any - /// temporarily hidden sessions appended in workspace order. - fn ordered_session_ids(&self) -> Vec { - let mut ordered = self.layout_state.assigned_sessions(); - if ordered.len() == self.sessions.len() { - return ordered; + /// Get persisted pane tab groups for the current layout. + pub fn pane_tab_groups(&self) -> Vec { + let mut groups = self.pane_tab_groups.values().cloned().collect::>(); + groups.sort_by_key(|group| match group.pane { + PaneId::GridCell { index } => (0u8, index), + PaneId::SplitSlot { slot } => (1u8, slot.0 as usize), + }); + groups + } + + /// Get persisted pane stacks for the entire workspace, including hidden stacks. + pub fn pane_stacks(&self) -> Vec { + self.current_pane_stacks_in_order() + .into_iter() + .map(|stack| PaneStackState { + session_ids: stack.session_ids, + active_session_id: stack.active_session_id, + }) + .collect() + } + + fn visible_pane_ids(&self) -> Vec { + Self::visible_pane_ids_for_state(&self.layout_state) + } + + fn visible_pane_ids_for_state(layout_state: &WorkspaceLayoutState) -> Vec { + match layout_state { + WorkspaceLayoutState::Grid(state) => (0..state.assignments().len()) + .map(|index| PaneId::GridCell { index }) + .collect(), + WorkspaceLayoutState::SplitTree(state) => state + .assignments() + .iter() + .map(|(slot, _)| PaneId::SplitSlot { slot: *slot }) + .collect(), } + } - let assigned: HashSet = ordered.iter().copied().collect(); - ordered.extend( + fn active_session_for_pane(&self, pane_id: PaneId) -> Option { + if let Some(group) = self.pane_tab_groups.get(&pane_id) { + return Some(group.active_session_id); + } + + match pane_id { + PaneId::GridCell { index } => self + .layout_state + .as_grid() + .and_then(|state| state.session_at(index)), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree() + .and_then(|state| state.session_at_slot(slot)), + } + } + + fn pane_id_for_session(&self, session_id: SessionId) -> Option { + if let Some((pane_id, _)) = self + .pane_tab_groups + .iter() + .find(|(_, group)| group.session_ids.contains(&session_id)) + { + return Some(pane_id.clone()); + } + + match &self.layout_state { + WorkspaceLayoutState::Grid(state) => state + .assignments() + .iter() + .enumerate() + .find(|(_, assigned)| **assigned == Some(session_id)) + .map(|(index, _)| PaneId::GridCell { index }), + WorkspaceLayoutState::SplitTree(state) => state + .slot_for_session(session_id) + .map(|slot| PaneId::SplitSlot { slot }), + } + } + + fn pane_sessions(&self, pane_id: PaneId) -> Vec { + if let Some(group) = self.pane_tab_groups.get(&pane_id) { + return group.session_ids.clone(); + } + + self.active_session_for_pane(pane_id).into_iter().collect() + } + + fn current_pane_stacks_in_order(&self) -> Vec { + if self + .layout_state + .as_grid() + .is_some_and(|state| state.profile() == LayoutProfile::Single) + && self.pane_tab_groups.is_empty() + && self + .hidden_pane_stacks + .iter() + .all(|stack| stack.session_ids.len() == 1) + { + return self + .sessions + .iter() + .map(|session| PaneStack { + session_ids: vec![session.id], + active_session_id: session.id, + }) + .collect(); + } + + let mut stacks = self + .visible_pane_ids() + .into_iter() + .filter_map(|pane_id| { + let session_ids = self.pane_sessions(pane_id.clone()); + let active_session_id = self.active_session_for_pane(pane_id)?; + (!session_ids.is_empty()).then_some(PaneStack { + session_ids, + active_session_id, + }) + }) + .collect::>(); + + let mut assigned: HashSet = stacks + .iter() + .flat_map(|stack| stack.session_ids.iter().copied()) + .collect(); + stacks.extend(self.hidden_pane_stacks.iter().filter_map(|stack| { + let session_ids = stack + .session_ids + .iter() + .copied() + .filter(|session_id| { + self.session(*session_id).is_some() && !assigned.contains(session_id) + }) + .collect::>(); + if session_ids.is_empty() { + return None; + } + + assigned.extend(session_ids.iter().copied()); + Some(PaneStack { + active_session_id: if session_ids.contains(&stack.active_session_id) { + stack.active_session_id + } else { + session_ids[0] + }, + session_ids, + }) + })); + stacks.extend( self.sessions .iter() .map(|session| session.id) - .filter(|session_id| !assigned.contains(session_id)), + .filter(|session_id| !assigned.contains(session_id)) + .map(|session_id| PaneStack { + session_ids: vec![session_id], + active_session_id: session_id, + }), + ); + + stacks + } + + fn apply_pane_stacks_to_current_layout(&mut self, stacks: Vec) { + let pane_ids = self.visible_pane_ids(); + self.pane_tab_groups.clear(); + self.hidden_pane_stacks.clear(); + + for (index, stack) in stacks.into_iter().enumerate() { + if let Some(pane_id) = pane_ids.get(index).cloned() { + if stack.session_ids.len() > 1 { + self.pane_tab_groups.insert( + pane_id.clone(), + PaneTabGroup { + pane: pane_id, + session_ids: stack.session_ids, + active_session_id: stack.active_session_id, + }, + ); + } + } else if !stack.session_ids.is_empty() { + self.hidden_pane_stacks.push(stack); + } + } + + self.cleanup_pane_tab_groups(); + } + + fn stack_session_order(stack: &PaneStack) -> Vec { + let mut ordered = vec![stack.active_session_id]; + ordered.extend( + stack + .session_ids + .iter() + .copied() + .filter(|session_id| *session_id != stack.active_session_id), + ); + ordered + } + + fn layout_session_order(stacks: &[PaneStack], visible_panes: usize) -> Vec { + let mut ordered = Vec::new(); + let visible_len = visible_panes.min(stacks.len()); + + ordered.extend( + stacks + .iter() + .take(visible_len) + .map(|stack| stack.active_session_id), ); + for stack in stacks.iter().take(visible_len) { + ordered.extend( + stack + .session_ids + .iter() + .copied() + .filter(|session_id| *session_id != stack.active_session_id), + ); + } + for stack in stacks.iter().skip(visible_len) { + ordered.extend(Self::stack_session_order(stack)); + } + ordered } - fn rebuild_grid_state(&self, profile: LayoutProfile) -> LayoutState { + fn pane_exists(&self, pane_id: &PaneId) -> bool { + match pane_id { + PaneId::GridCell { index } => self + .layout_state + .as_grid() + .is_some_and(|state| *index < state.assignments().len()), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree() + .is_some_and(|state| state.tree().contains_slot(*slot)), + } + } + + fn cleanup_pane_tab_groups(&mut self) { + let valid_sessions: HashSet = + self.sessions.iter().map(|session| session.id).collect(); + let valid_panes: HashSet = self.visible_pane_ids().into_iter().collect(); + self.pane_tab_groups.retain(|pane_id, group| { + if !valid_panes.contains(pane_id) { + return false; + } + + group + .session_ids + .retain(|session_id| valid_sessions.contains(session_id)); + if !group.session_ids.contains(&group.active_session_id) { + if let Some(session_id) = group.session_ids.first().copied() { + group.active_session_id = session_id; + } + } + + group.session_ids.len() > 1 + }); + + let mut claimed_sessions: HashSet = self + .visible_pane_ids() + .into_iter() + .flat_map(|pane_id| self.pane_sessions(pane_id).into_iter()) + .collect(); + self.hidden_pane_stacks.retain_mut(|stack| { + stack.session_ids.retain(|session_id| { + valid_sessions.contains(session_id) && !claimed_sessions.contains(session_id) + }); + if stack.session_ids.is_empty() { + return false; + } + if !stack.session_ids.contains(&stack.active_session_id) { + stack.active_session_id = stack.session_ids[0]; + } + claimed_sessions.extend(stack.session_ids.iter().copied()); + true + }); + } + + fn set_pane_active_session(&mut self, pane_id: PaneId, session_id: SessionId) -> bool { + match pane_id { + PaneId::GridCell { index } => self + .layout_state + .as_grid_mut() + .and_then(|state| state.replace_session_in_index(index, session_id)) + .is_some(), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree_mut() + .and_then(|state| state.replace_session_in_slot(slot, session_id)) + .is_some(), + } + } + + fn remove_session_from_pane_group(&mut self, pane_id: PaneId, session_id: SessionId) { + let Some(mut group) = self.pane_tab_groups.remove(&pane_id) else { + return; + }; + + let was_active = group.active_session_id == session_id; + group.session_ids.retain(|current| *current != session_id); + + if group.session_ids.len() < 2 { + if was_active { + if let Some(next_active) = group.session_ids.first().copied() { + let _ = self.set_pane_active_session(pane_id.clone(), next_active); + } + } + return; + } + + if was_active { + if let Some(next_active) = group.session_ids.first().copied() { + if self.set_pane_active_session(pane_id.clone(), next_active) { + group.active_session_id = next_active; + } + } + } + + if group.session_ids.len() > 1 { + self.pane_tab_groups.insert(pane_id, group); + } + } + + fn insert_session_into_pane_group( + &mut self, + pane_id: PaneId, + session_id: SessionId, + make_active: bool, + ) -> bool { + let mut group = self + .pane_tab_groups + .remove(&pane_id) + .unwrap_or(PaneTabGroup { + pane: pane_id.clone(), + session_ids: self.pane_sessions(pane_id.clone()), + active_session_id: self + .active_session_for_pane(pane_id.clone()) + .unwrap_or(session_id), + }); + + if group.session_ids.is_empty() { + group.session_ids.push(session_id); + group.active_session_id = session_id; + } else if !group.session_ids.contains(&session_id) { + group.session_ids.push(session_id); + } + + if make_active { + if !self.set_pane_active_session(pane_id.clone(), session_id) { + self.pane_tab_groups.insert(pane_id, group); + return false; + } + group.active_session_id = session_id; + } + + if group.session_ids.len() > 1 { + self.pane_tab_groups.insert(pane_id, group); + } + true + } + + fn swap_pane_groups(&mut self, pane_a: PaneId, pane_b: PaneId) { + let group_a = self.pane_tab_groups.remove(&pane_a); + let group_b = self.pane_tab_groups.remove(&pane_b); + + if let Some(mut group) = group_a { + group.pane = pane_b.clone(); + self.pane_tab_groups.insert(pane_b, group); + } + + if let Some(mut group) = group_b { + group.pane = pane_a.clone(); + self.pane_tab_groups.insert(pane_a, group); + } + } + + fn rebuild_grid_state(&self, profile: LayoutProfile, stacks: &[PaneStack]) -> LayoutState { let focused = self.layout_state.focused_session(); let mut grid_state = LayoutState::with_profile(profile); - grid_state.set_assignments(self.ordered_session_ids()); + grid_state.set_assignments(Self::layout_session_order(stacks, profile.max_sessions())); if let Some(session_id) = focused { - grid_state.focus_session(session_id); + if !grid_state.focus_session(session_id) && profile == LayoutProfile::Single { + let _ = grid_state.swap_hidden_into_index(session_id, 0); + } } if grid_state.focused_session().is_none() { - if let Some(session_id) = grid_state.assignments().first().copied() { + if let Some(session_id) = grid_state.assignments().iter().flatten().copied().next() { grid_state.focus_session(session_id); } } + if profile != LayoutProfile::Single { + let visible_len = grid_state.occupied_indices().len(); + if grid_state + .focused_index() + .is_some_and(|index| index >= profile.max_sessions()) + && visible_len > 0 + { + if let Some(index) = grid_state.occupied_indices().into_iter().next() { + grid_state.focus_index(index); + } + } + } + grid_state } - fn rebuild_split_state(&self, tree: LayoutNode) -> SplitLayoutState { + fn rebuild_split_state(&self, tree: LayoutNode, stacks: &[PaneStack]) -> SplitLayoutState { let focused = self.layout_state.focused_session(); let mut split_state = SplitLayoutState::new(tree); - for session_id in self.ordered_session_ids() { + for session_id in Self::layout_session_order(stacks, split_state.slot_count()) { if !split_state.add_session(session_id) { break; } @@ -299,10 +690,10 @@ impl Workspace { return false; } - // Try to add to layout - if !self.layout_state.add_session(id) { - return false; - } + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) => s.add_session(id), + WorkspaceLayoutState::SplitTree(s) => s.add_session(id), + }; self.sessions.push(session); @@ -330,18 +721,68 @@ impl Workspace { true } + /// Add a session to a specific visible pane as a new active tab. + pub fn add_session_to_pane(&mut self, session: Session, pane_id: PaneId) -> bool { + let id = session.id; + if self.sessions.iter().any(|existing| existing.id == id) { + return false; + } + + self.sessions.push(session); + + if self.active_session_for_pane(pane_id.clone()).is_none() { + match pane_id { + PaneId::GridCell { index } => { + let Some(state) = self.layout_state.as_grid_mut() else { + self.sessions.retain(|session| session.id != id); + return false; + }; + if !state.assign_session_to_index(id, index) { + self.sessions.retain(|session| session.id != id); + return false; + } + state.focus_index(index); + } + PaneId::SplitSlot { slot } => { + let Some(state) = self.layout_state.as_split_tree_mut() else { + self.sessions.retain(|session| session.id != id); + return false; + }; + if !state.assign_session_to_slot(id, slot) { + self.sessions.retain(|session| session.id != id); + return false; + } + state.focus_slot(slot); + } + } + return true; + } + + if !self.insert_session_into_pane_group(pane_id, id, true) { + self.sessions.retain(|session| session.id != id); + return false; + } + + true + } + /// Remove a session from the workspace. /// /// # Returns /// /// The removed session, if found. pub fn remove_session(&mut self, id: SessionId) -> Option { + if let Some(pane_id) = self.pane_id_for_session(id) { + self.remove_session_from_pane_group(pane_id, id); + } + // Remove from layout self.layout_state.remove_session(id); // Remove from sessions list if let Some(pos) = self.sessions.iter().position(|s| s.id == id) { let removed = self.sessions.remove(pos); + self.cleanup_pane_tab_groups(); self.promote_hidden_sessions_into_split_slots(); Some(removed) } else { @@ -371,6 +812,7 @@ impl Workspace { if let Some(dst) = self.session_mut(src.id) { dst.name = src.name.clone(); dst.working_directory = src.working_directory.clone(); + dst.shell = src.shell.clone(); dst.current_task = src.current_task.clone(); dst.context_usage = src.context_usage; dst.group = src.group.clone(); @@ -389,20 +831,31 @@ impl Workspace { /// Get the visible sessions (those assigned to cells/slots). pub fn visible_sessions(&self) -> Vec<&Session> { - self.layout_state - .assigned_sessions() - .iter() - .filter_map(|id| self.session(*id)) + self.visible_session_ids() + .into_iter() + .filter_map(|id| self.session(id)) .collect() } + /// Get the IDs of sessions currently visible in rendered panes. + pub fn visible_session_ids(&self) -> Vec { + self.visible_pane_ids() + .into_iter() + .filter_map(|pane_id| self.active_session_for_pane(pane_id)) + .collect() + } + + /// Returns whether a session is currently visible in the active layout. + pub fn is_session_visible(&self, session_id: SessionId) -> bool { + self.pane_id_for_session(session_id).is_some() + } + /// Get the number of sessions that can still be added. pub fn available_slots(&self) -> usize { match &self.layout_state { - WorkspaceLayoutState::Grid(s) => s - .profile() - .max_sessions() - .saturating_sub(s.assignments().len()), + WorkspaceLayoutState::Grid(s) => { + s.assignments().iter().filter(|cell| cell.is_none()).count() + } WorkspaceLayoutState::SplitTree(s) => s.available_slots(), } } @@ -419,13 +872,123 @@ impl Workspace { self.focused_session_id().and_then(|id| self.session(id)) } + /// Get the currently focused visible pane. + pub fn focused_pane_id(&self) -> Option { + match &self.layout_state { + WorkspaceLayoutState::Grid(state) => state + .focused_index() + .map(|index| PaneId::GridCell { index }), + WorkspaceLayoutState::SplitTree(state) => { + state.focused_slot().map(|slot| PaneId::SplitSlot { slot }) + } + } + } + + /// Get all sessions currently attached to the focused visible pane. + pub fn focused_pane_session_ids(&self) -> Vec { + self.focused_pane_id() + .map(|pane_id| self.pane_sessions(pane_id)) + .unwrap_or_default() + } + /// Focus a session by ID. /// /// # Returns /// /// `true` if the session was found and focused. pub fn focus_session(&mut self, id: SessionId) -> bool { - self.layout_state.focus_session(id) + if self.session(id).is_none() { + return false; + } + + if let Some(pane_id) = self.pane_id_for_session(id) { + let should_activate = self + .pane_tab_groups + .get(&pane_id) + .is_some_and(|group| group.session_ids.contains(&id)) + && self.active_session_for_pane(pane_id.clone()) != Some(id); + + if should_activate { + if !self.set_pane_active_session(pane_id.clone(), id) { + return false; + } + if let Some(group) = self.pane_tab_groups.get_mut(&pane_id) { + group.active_session_id = id; + } + } + } + + let focused = match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) => { + if s.profile() == LayoutProfile::Single { + if let Some(visible_index) = + s.assignments().iter().position(|cell| *cell == Some(id)) + { + s.focus_index(visible_index); + true + } else { + let replacement_index = s.focused_index().or(Some(0)); + let Some(replacement_index) = replacement_index else { + return false; + }; + s.swap_hidden_into_index(id, replacement_index).is_some() + } + } else if let Some(target_index) = + s.assignments().iter().position(|cell| *cell == Some(id)) + { + s.focus_index(target_index); + true + } else { + let replacement_index = s + .focused_index() + .or_else(|| s.occupied_indices().into_iter().next()) + .or_else(|| s.first_empty_index()); + + let Some(replacement_index) = replacement_index else { + return false; + }; + + s.swap_hidden_into_index(id, replacement_index).is_some() + } + } + WorkspaceLayoutState::SplitTree(s) => { + if s.focus_session(id) { + true + } else { + let target_slot = s + .focused_slot() + .or_else(|| { + s.assignments().iter().find_map(|(slot, session)| { + if session.is_some() + || self + .pane_tab_groups + .contains_key(&PaneId::SplitSlot { slot: *slot }) + { + Some(*slot) + } else { + None + } + }) + }) + .or_else(|| { + s.assignments() + .iter() + .find_map(|(slot, session)| session.is_none().then_some(*slot)) + }); + + let Some(target_slot) = target_slot else { + return false; + }; + + s.replace_session_in_slot(target_slot, id).is_some() + } + } + }; + + if focused { + self.cleanup_pane_tab_groups(); + } + focused } /// Focus a session by grid index (1-based, for keyboard shortcuts). @@ -436,10 +999,11 @@ impl Workspace { pub fn focus_session_number(&mut self, number: usize) -> bool { match &mut self.layout_state { WorkspaceLayoutState::Grid(s) => { - if number == 0 || number > s.assignments().len() { + let visible_indices = s.occupied_indices(); + if number == 0 || number > visible_indices.len() { return false; } - s.focus_index(number - 1); + s.focus_index(visible_indices[number - 1]); true } WorkspaceLayoutState::SplitTree(s) => { @@ -454,12 +1018,45 @@ impl Workspace { /// Focus the next session. pub fn focus_next(&mut self) { - self.layout_state.focus_next(); + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) if s.profile() != LayoutProfile::Single => { + let occupied = s.occupied_indices(); + if occupied.is_empty() { + return; + } + let next = match s + .focused_index() + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { + Some(index) => (index + 1) % occupied.len(), + None => 0, + }; + s.focus_index(occupied[next]); + } + _ => self.layout_state.focus_next(), + } } /// Focus the previous session. pub fn focus_previous(&mut self) { - self.layout_state.focus_previous(); + match &mut self.layout_state { + WorkspaceLayoutState::Grid(s) if s.profile() != LayoutProfile::Single => { + let occupied = s.occupied_indices(); + if occupied.is_empty() { + return; + } + let prev = match s + .focused_index() + .and_then(|index| occupied.iter().position(|¤t| current == index)) + { + Some(index) if index > 0 => index - 1, + Some(_) => occupied.len() - 1, + None => 0, + }; + s.focus_index(occupied[prev]); + } + _ => self.layout_state.focus_previous(), + } } /// Focus in a direction (for arrow key navigation). @@ -480,7 +1077,10 @@ impl Workspace { /// /// Transfers current sessions and focus to the new tree. pub fn set_split_tree(&mut self, tree: LayoutNode) { - self.layout_state = WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree)); + let stacks = self.current_pane_stacks_in_order(); + self.layout_state = + WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } // --- Split Pane Operations --- @@ -516,6 +1116,8 @@ impl Workspace { let (result, should_switch_to_single) = if let WorkspaceLayoutState::SplitTree(s) = &mut self.layout_state { if let Some(target) = s.focused_slot() { + self.pane_tab_groups + .remove(&PaneId::SplitSlot { slot: target }); let result = s.close_slot(target); let should_switch = result && s.slot_count() == 1; (result, should_switch) @@ -527,8 +1129,10 @@ impl Workspace { }; if should_switch_to_single { + let stacks = self.current_pane_stacks_in_order(); self.layout_state = - WorkspaceLayoutState::Grid(self.rebuild_grid_state(LayoutProfile::Single)); + WorkspaceLayoutState::Grid(self.rebuild_grid_state(LayoutProfile::Single, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } result @@ -544,8 +1148,23 @@ impl Workspace { false } + /// Resize a specific split divider identified by representative slots on + /// either side of the split. + pub fn resize_split_divider( + &mut self, + first_slot: SlotId, + second_slot: SlotId, + new_ratio: f32, + ) -> bool { + if let WorkspaceLayoutState::SplitTree(s) = &mut self.layout_state { + return s.resize_divider(first_slot, second_slot, new_ratio); + } + false + } + /// Convert the current grid layout to an equivalent split tree. fn convert_to_split_tree(&mut self) { + let stacks = self.current_pane_stacks_in_order(); let tree = if let WorkspaceLayoutState::Grid(grid_state) = &self.layout_state { let (rows, cols) = grid_state.profile().dimensions(); Some(LayoutNode::from_grid(rows, cols)) @@ -554,7 +1173,9 @@ impl Workspace { }; if let Some(tree) = tree { - self.layout_state = WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree)); + self.layout_state = + WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree, &stacks)); + self.apply_pane_stacks_to_current_layout(stacks); } } @@ -680,19 +1301,15 @@ impl Workspace { /// Get the bounds for a session's cell. pub fn session_cell_bounds(&self, id: SessionId) -> Option { - match &self.layout_state { - WorkspaceLayoutState::Grid(s) => { - let index = s.assignments().iter().position(|&sid| sid == id)?; - self.grid_layout().cell_bounds_for_index(index) - } - WorkspaceLayoutState::SplitTree(s) => { - let slot = s.slot_for_session(id)?; + match self.pane_id_for_session(id)? { + PaneId::GridCell { index } => self.grid_layout().cell_bounds_for_index(index), + PaneId::SplitSlot { slot } => { let layout = self.split_layout()?; layout .leaf_bounds() .into_iter() - .find(|(sid, _)| *sid == slot) - .map(|(_, b)| b) + .find(|(current, _)| *current == slot) + .map(|(_, bounds)| bounds) } } } @@ -736,8 +1353,37 @@ impl Workspace { /// Returns `true` if the swap was performed. pub fn swap_sessions(&mut self, index_a: usize, index_b: usize) -> bool { match &mut self.layout_state { - WorkspaceLayoutState::Grid(s) => s.swap_assignments(index_a, index_b), - WorkspaceLayoutState::SplitTree(s) => s.swap_assignments(index_a, index_b), + WorkspaceLayoutState::Grid(s) => { + let swapped = s.swap_assignments(index_a, index_b); + if swapped { + self.swap_pane_groups( + PaneId::GridCell { index: index_a }, + PaneId::GridCell { index: index_b }, + ); + } + swapped + } + WorkspaceLayoutState::SplitTree(s) => { + let Some(pane_a) = s + .assignments() + .get(index_a) + .map(|(slot, _)| PaneId::SplitSlot { slot: *slot }) + else { + return false; + }; + let Some(pane_b) = s + .assignments() + .get(index_b) + .map(|(slot, _)| PaneId::SplitSlot { slot: *slot }) + else { + return false; + }; + let swapped = s.swap_assignments(index_a, index_b); + if swapped { + self.swap_pane_groups(pane_a, pane_b); + } + swapped + } } } @@ -764,6 +1410,7 @@ impl Workspace { if self.session(focused_session_id).is_some() { if let Some(bounds) = layout.cell_bounds_for_index(0) { return vec![CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: focused_session_id, index: 0, bounds, @@ -780,10 +1427,12 @@ impl Workspace { .assignments() .iter() .enumerate() - .filter_map(|(index, &session_id)| { + .filter_map(|(index, session_id)| { + let session_id = (*session_id)?; let bounds = layout.cell_bounds_for_index(index)?; self.session(session_id)?; Some(CellInfo { + pane_id: PaneId::GridCell { index }, session_id, index, bounds, @@ -805,6 +1454,7 @@ impl Workspace { let session_id = state.session_at_slot(slot)?; self.session(session_id)?; Some(CellInfo { + pane_id: PaneId::SplitSlot { slot }, session_id, index, bounds, @@ -812,11 +1462,202 @@ impl Workspace { }) .collect() } + + /// Get all tab sessions for a pane in display order. + pub fn pane_tab_session_ids(&self, pane_id: PaneId) -> Vec { + self.pane_sessions(pane_id) + } + + /// Get the active session rendered in a pane. + pub fn pane_active_session_id(&self, pane_id: PaneId) -> Option { + self.active_session_for_pane(pane_id) + } + + /// Activate a tab within a visible pane. + pub fn activate_pane_tab(&mut self, pane_id: PaneId, session_id: SessionId) -> bool { + let Some(group) = self.pane_tab_groups.get(&pane_id) else { + return false; + }; + if !group.session_ids.contains(&session_id) { + return false; + } + if !self.set_pane_active_session(pane_id.clone(), session_id) { + return false; + } + if let Some(group) = self.pane_tab_groups.get_mut(&pane_id) { + group.active_session_id = session_id; + } + self.focus_session(session_id) + } + + /// Group a session into the target pane as an active tab. + pub fn group_session_into_pane(&mut self, session_id: SessionId, target_pane: PaneId) -> bool { + let Some(source_pane) = self.pane_id_for_session(session_id) else { + return false; + }; + if source_pane == target_pane { + return false; + } + + if !self.pane_exists(&target_pane) { + return false; + } + + let removed_from_source = match source_pane.clone() { + PaneId::GridCell { index } => { + if self.pane_tab_groups.contains_key(&source_pane) { + self.remove_session_from_pane_group(source_pane.clone(), session_id); + true + } else { + self.layout_state + .as_grid_mut() + .and_then(|state| state.clear_index(index)) + == Some(session_id) + } + } + PaneId::SplitSlot { .. } => { + if self.pane_tab_groups.contains_key(&source_pane) { + self.remove_session_from_pane_group(source_pane.clone(), session_id); + true + } else { + self.layout_state.remove_session(session_id) + } + } + }; + + if !removed_from_source { + return false; + } + + if self.active_session_for_pane(target_pane.clone()).is_none() { + let assigned = match target_pane.clone() { + PaneId::GridCell { index } => self + .layout_state + .as_grid_mut() + .is_some_and(|state| state.assign_session_to_index(session_id, index)), + PaneId::SplitSlot { slot } => self + .layout_state + .as_split_tree_mut() + .is_some_and(|state| state.assign_session_to_slot(session_id, slot)), + }; + if assigned { + self.cleanup_pane_tab_groups(); + return self.focus_session(session_id); + } + return false; + } + + let grouped = self.insert_session_into_pane_group(target_pane, session_id, true); + self.cleanup_pane_tab_groups(); + grouped && self.focus_session(session_id) + } + + /// Restore persisted pane stacks after sessions have been recreated. + pub fn restore_pane_stacks( + &mut self, + saved_stacks: &[PaneStackState], + restored_session_ids: &HashMap, + ) { + let stacks = saved_stacks + .iter() + .filter_map(|saved_stack| { + let session_ids = saved_stack + .session_ids + .iter() + .filter_map(|saved_id| restored_session_ids.get(saved_id).copied()) + .collect::>(); + if session_ids.is_empty() { + return None; + } + + Some(PaneStack { + active_session_id: restored_session_ids + .get(&saved_stack.active_session_id) + .copied() + .filter(|session_id| session_ids.contains(session_id)) + .unwrap_or(session_ids[0]), + session_ids, + }) + }) + .collect::>(); + self.restore_runtime_pane_stacks(stacks); + } + + fn restore_runtime_pane_stacks(&mut self, mut stacks: Vec) { + let assigned: HashSet = stacks + .iter() + .flat_map(|stack| stack.session_ids.iter().copied()) + .collect(); + stacks.extend( + self.sessions + .iter() + .map(|session| session.id) + .filter(|session_id| !assigned.contains(session_id)) + .map(|session_id| PaneStack { + session_ids: vec![session_id], + active_session_id: session_id, + }), + ); + + let rebuilt_layout = match &self.layout_state { + WorkspaceLayoutState::Grid(state) => { + WorkspaceLayoutState::Grid(self.rebuild_grid_state(state.profile(), &stacks)) + } + WorkspaceLayoutState::SplitTree(state) => WorkspaceLayoutState::SplitTree( + self.rebuild_split_state(state.tree().clone(), &stacks), + ), + }; + self.layout_state = rebuilt_layout; + self.apply_pane_stacks_to_current_layout(stacks); + } + + /// Restore legacy persisted pane tab groups after sessions have been recreated. + pub fn restore_pane_tab_groups( + &mut self, + saved_groups: &[PaneTabGroup], + restored_session_ids: &HashMap, + ) { + for group in saved_groups { + if !self.pane_exists(&group.pane) { + continue; + } + let session_ids = group + .session_ids + .iter() + .filter_map(|saved_id| restored_session_ids.get(saved_id).copied()) + .collect::>(); + if session_ids.is_empty() { + continue; + } + + let active_session_id = restored_session_ids + .get(&group.active_session_id) + .copied() + .filter(|session_id| session_ids.contains(session_id)) + .unwrap_or(session_ids[0]); + + for session_id in &session_ids { + if self.pane_id_for_session(*session_id) != Some(group.pane.clone()) { + let _ = self.group_session_into_pane(*session_id, group.pane.clone()); + } + } + + if let Some(restored_group) = self.pane_tab_groups.get_mut(&group.pane) { + restored_group.session_ids = session_ids.clone(); + restored_group.active_session_id = active_session_id; + } + let _ = self.activate_pane_tab(group.pane.clone(), active_session_id); + } + + self.cleanup_pane_tab_groups(); + } } /// Information about a grid cell for rendering. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct CellInfo { + /// Visible pane identifier. + pub pane_id: PaneId, /// Session ID. pub session_id: SessionId, /// Grid index (0-based). diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs index 371c2ec5..60533969 100644 --- a/crates/codirigent-ui/src/workspace/drawer_render.rs +++ b/crates/codirigent-ui/src/workspace/drawer_render.rs @@ -124,6 +124,8 @@ impl WorkspaceView { let sessions: Vec = self.workspace().sessions().to_vec(); let focused_id = self.workspace().focused_session_id(); + let visible_session_ids: std::collections::HashSet = + self.workspace().visible_session_ids().into_iter().collect(); let session_count = sessions.len(); // Separate ungrouped and grouped sessions @@ -143,7 +145,13 @@ impl WorkspaceView { // Render ungrouped sessions first for session in &ungrouped { - content = content.child(self.render_session_row(session, focused_id, &theme, cx)); + content = content.child(self.render_session_row( + session, + focused_id, + visible_session_ids.contains(&session.id), + &theme, + cx, + )); } // Render grouped sessions with headers @@ -163,8 +171,13 @@ impl WorkspaceView { if expanded { for session in group_sessions { - content = - content.child(self.render_session_row(session, focused_id, &theme, cx)); + content = content.child(self.render_session_row( + session, + focused_id, + visible_session_ids.contains(&session.id), + &theme, + cx, + )); } } } @@ -1033,6 +1046,7 @@ impl WorkspaceView { &mut self, session: &Session, focused_id: Option, + is_visible: bool, theme: &CodirigentTheme, cx: &mut Context, ) -> impl IntoElement { @@ -1040,16 +1054,20 @@ impl WorkspaceView { let fg: gpui::Hsla = theme.foreground.into(); let status_color: gpui::Hsla = theme.status_color(session.status).into(); let is_focused = focused_id == Some(session.id); + let is_hidden = !is_visible; let row_bg = if is_focused { theme.active.into() } else { gpui::Hsla::transparent_black() }; let hover_bg: gpui::Hsla = theme.active.into(); + let orange: gpui::Hsla = theme.orange.into(); let session_id = session.id; let session_name = session.name.clone(); let context_pct = session.context_usage; + let (shell_label, shell_warning) = + self.session_shell_display(session_id, session.shell.as_deref()); div() .id(SharedString::from(format!("session-row-{}", session_id.0))) @@ -1088,6 +1106,37 @@ impl WorkspaceView { .text_color(if is_focused { fg } else { muted }) .child(session_name), ) + .when(is_hidden, |el| { + el.child( + div() + .text_xs() + .text_color(muted.opacity(0.75)) + .flex_shrink_0() + .child("Hidden"), + ) + }) + .child( + div() + .flex_shrink_0() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(if shell_warning.is_some() { + orange.opacity(0.12) + } else { + muted.opacity(0.12) + }) + .child( + div() + .text_xs() + .text_color(if shell_warning.is_some() { + orange + } else { + muted.opacity(0.75) + }) + .child(shell_label), + ), + ) // Git branch (compact) - between name and context% .when_some(session.git_info.as_ref(), |el, gi| { let mut branch = gi.branch.clone(); diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index e96ec4ad..df4bb6f6 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -710,6 +710,8 @@ impl WorkspaceView { this.persistence.storage.clone(), this.session_manager.clone(), this.persisted_layout_mode(), + this.workspace.pane_tab_groups(), + this.workspace.pane_stacks(), this.cache.last_window_state.clone(), )) }) { @@ -717,7 +719,8 @@ impl WorkspaceView { Ok(None) | Err(_) => return, }; - let (storage, session_manager, layout, window_state) = save_inputs; + let (storage, session_manager, layout, pane_tab_groups, pane_stacks, window_state) = + save_inputs; let result = cx .background_executor() .spawn(async move { @@ -728,6 +731,8 @@ impl WorkspaceView { let state = codirigent_core::AppState { sessions, layout, + pane_tab_groups, + pane_stacks, updated_at: Some(chrono::Utc::now()), window_bounds: window_state, }; @@ -1129,6 +1134,9 @@ impl WorkspaceView { if let Some(modal) = self.render_session_action_modal(cx) { container = container.child(modal); } + if let Some(modal) = self.render_session_creation_modal(cx) { + container = container.child(modal); + } if let Some(modal) = self.render_task_creation_modal(cx) { container = container.child(modal); } @@ -1297,6 +1305,9 @@ impl WorkspaceView { if self.handle_session_action_key_down(event, cx) { return true; } + if self.handle_session_creation_key_down(event, cx) { + return true; + } if self.handle_task_creation_key_down(event, cx) { return true; } @@ -1594,30 +1605,13 @@ impl Render for WorkspaceView { this.handle_key_down(event, window, cx); })) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _window, cx| { - let Some(drag) = &mut this.selection.drag else { - return; - }; - - let pos = - crate::layout::Point::new(event.position.x.into(), event.position.y.into()); - drag.update_pointer(pos, &this.cache.render_cell_info); - cx.notify(); + this.handle_workspace_mouse_move(event, cx); })) // Global mouse-up: catch drag releases anywhere in workspace .on_mouse_up( MouseButton::Left, - cx.listener(|this, _event: &MouseUpEvent, _window, cx| { - if let Some(drag) = this.selection.drag.take() { - if drag.active { - if let Some(target) = drag.target_index { - this.workspace.swap_sessions(drag.source_index, target); - this.mark_layout_cache_dirty(); - this.sync_layout_derived_state(); - this.save_state_to_disk(cx); - } - } - cx.notify(); - } + cx.listener(|this, event: &MouseUpEvent, _window, cx| { + this.handle_workspace_mouse_up_left(event, cx); }), ) .bg(bg) diff --git a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs index 28964a8b..caedb44c 100644 --- a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs +++ b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs @@ -139,6 +139,8 @@ impl WorkspaceView { .map(crate::sidebar::Color::from_hex) .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); let task = self.task_title_for_session(session, task_titles); + let (shell_label, shell_warning) = + self.session_shell_display(session.id, session.shell.as_deref()); if let Some(header) = self.terminal_headers.get_mut(&session.id) { if header.session_name != session.name { header.session_name = session.name.clone(); @@ -164,6 +166,12 @@ impl WorkspaceView { if header.task != task { header.task = task; } + if header.shell_label.as_deref() != Some(shell_label.as_str()) { + header.shell_label = Some(shell_label.clone()); + } + if header.shell_warning != shell_warning { + header.shell_warning = shell_warning.clone(); + } } } } @@ -172,15 +180,22 @@ impl WorkspaceView { let (rows, cols) = self.workspace.layout_profile().dimensions(); let occupied: Vec = self .workspace - .sessions() - .iter() - .enumerate() - .map(|(i, _)| { - let row = i as u32 / cols; - let col = i as u32 % cols; - codirigent_core::GridPosition { row, col } + .layout_state() + .as_grid() + .map(|state| { + state + .assignments() + .iter() + .enumerate() + .filter_map(|(index, session_id)| { + session_id.map(|_| codirigent_core::GridPosition { + row: index as u32 / cols, + col: index as u32 % cols, + }) + }) + .collect() }) - .collect(); + .unwrap_or_default(); self.empty_cells.setup_for_grid(rows, cols, &occupied); } @@ -222,6 +237,8 @@ impl WorkspaceView { .map(crate::sidebar::Color::from_hex) .unwrap_or_else(|| crate::sidebar::Color::from_hex("#6366f1")); let task = self.task_title_for_session(session, None); + let (shell_label, shell_warning) = + self.session_shell_display(session.id, session.shell.as_deref()); if let Some(header) = self.terminal_headers.get_mut(&session_id) { header.status = session.status; @@ -251,6 +268,12 @@ impl WorkspaceView { if header.task != task { header.task = task; } + if header.shell_label.as_deref() != Some(shell_label.as_str()) { + header.shell_label = Some(shell_label); + } + if header.shell_warning != shell_warning { + header.shell_warning = shell_warning; + } } } } diff --git a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs index 17aab8a6..a3fc7225 100644 --- a/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs +++ b/crates/codirigent-ui/src/workspace/gpui/layout_sync.rs @@ -130,7 +130,7 @@ impl WorkspaceView { // Layout constants from types.rs: HEADER_HEIGHT, TERMINAL_CONTENT_PADDING, CELL_BORDER_WIDTH let mut resized_any = false; - for &info in &self.cache.render_cell_info { + for info in &self.cache.render_cell_info { if let Some(terminal_view) = self.terminals.get_mut(&info.session_id) { // Subtract all chrome between the grid cell bounds and the // actual terminal canvas drawing area: diff --git a/crates/codirigent-ui/src/workspace/gpui/ui_events.rs b/crates/codirigent-ui/src/workspace/gpui/ui_events.rs index 2b0dda5b..33d5fe52 100644 --- a/crates/codirigent-ui/src/workspace/gpui/ui_events.rs +++ b/crates/codirigent-ui/src/workspace/gpui/ui_events.rs @@ -123,7 +123,9 @@ impl WorkspaceView { EmptySessionEvent::CreateSessionClicked { position } => { info!(?position, "Create session at position"); if self.should_create_session_at(position) { - self.create_session(cx); + let cols = self.workspace.layout_profile().dimensions().1; + let index = (position.row * cols + position.col) as usize; + self.create_session_in_pane(codirigent_core::PaneId::GridCell { index }, cx); } } } diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index d4ae03af..289f7928 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -2,23 +2,20 @@ //! //! This module handles rendering of the workspace grid layout, including: //! - Traditional NxM grid layout -//! - Split tree (binary tree) layout +//! - Dispatch to split-tree rendering //! - Session cells with terminals -//! - Empty cells and placeholders +//! - Empty grid cells and placeholders -use crate::icons; use crate::terminal_header::TerminalHeaderRenderHints; use crate::theme::CodirigentTheme; use crate::workspace::gpui::WorkspaceView; use crate::workspace::types::HEADER_HEIGHT; -use codirigent_core::{LayoutNode, SessionId, SlotId, SplitDirection}; +use codirigent_core::SessionId; use gpui::{ - div, px, relative, ClickEvent, Context, Focusable, FontWeight, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, - SharedString, StatefulInteractiveElement, Styled, Window, + div, px, Context, Focusable, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement, ScrollWheelEvent, SharedString, Styled, Window, }; use std::rc::Rc; -use tracing::info; /// Visual state of a cell during drag-and-drop. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -37,7 +34,7 @@ impl WorkspaceView { cx: &mut Context, ) -> gpui::AnyElement { if self.workspace().is_split_tree_mode() { - self.render_split_tree_layout(window, cx).into_any_element() + self.render_split_tree_layout(window, cx) } else { self.render_grid_layout(window, cx).into_any_element() } @@ -76,7 +73,13 @@ impl WorkspaceView { let index = (row * cols + col) as usize; let position = codirigent_core::GridPosition { row, col }; - let cell_div = if let Some(info) = self.cache.render_cell_info.get(index).copied() { + let cell_div = if let Some(info) = self + .cache + .render_cell_info + .iter() + .find(|info| info.index == index) + .cloned() + { // Get or create terminal header hints let header_hints = if let Some(header) = self.get_terminal_header(info.session_id) { @@ -106,6 +109,7 @@ impl WorkspaceView { // Render session cell with actual terminal content self.render_session_cell_with_terminal( + info.pane_id.clone(), info.session_id, &header_hints, &theme, @@ -136,204 +140,11 @@ impl WorkspaceView { grid } - /// Render the split tree layout using recursive binary tree traversal. - fn render_split_tree_layout( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> impl IntoElement { - let theme = self.workspace().theme().clone(); - let grid_gap = theme.grid_gap; - - // Collect split tree state needed for rendering - let tree = match self.workspace().layout_state() { - crate::layout::WorkspaceLayoutState::SplitTree(s) => s.tree().clone(), - _ => return div().flex_1().into_any_element(), - }; - - // Pre-compute colors once to avoid redundant conversions on every recursive call - let panel_bg: gpui::Hsla = theme.panel_background.into(); - let border_color: gpui::Hsla = theme.border.into(); - let muted: gpui::Hsla = theme.muted.into(); - - self.render_split_node( - &tree, - &theme, - grid_gap, - panel_bg, - border_color, - muted, - window, - cx, - ) - } - - /// Recursively render a layout node in the split tree. - #[allow(clippy::too_many_arguments)] - fn render_split_node( - &mut self, - node: &LayoutNode, - theme: &CodirigentTheme, - gap: f32, - panel_bg: gpui::Hsla, - border_color: gpui::Hsla, - muted: gpui::Hsla, - window: &mut Window, - cx: &mut Context, - ) -> gpui::AnyElement { - match node { - LayoutNode::Leaf { slot } => { - let focused_session = self.workspace().focused_session_id(); - let session_data = self - .workspace() - .layout_state() - .as_split_tree() - .and_then(|state| state.session_at_slot(*slot)) - .and_then(|session_id| { - self.workspace().session(session_id).map(|session| { - ( - session_id, - focused_session == Some(session_id), - session.name.clone(), - session.status, - ) - }) - }); - - if let Some((session_id, is_focused, name, status)) = session_data { - let header_hints = if let Some(header) = self.get_terminal_header(session_id) { - header.render_hints() - } else { - crate::terminal_header::TerminalHeader::new(name, status) - .with_focused(is_focused) - .render_hints() - }; - - self.render_session_cell_with_terminal( - session_id, - &header_hints, - theme, - None, - window, - cx, - ) - .into_any_element() - } else { - // Empty slot - self.render_split_empty_slot(*slot, panel_bg, border_color, muted, cx) - .into_any_element() - } - } - LayoutNode::Split { - direction, - ratio, - first, - second, - } => { - // Render children recursively (pass pre-computed colors to avoid per-call conversion) - let first_elem = self.render_split_node( - first, - theme, - gap, - panel_bg, - border_color, - muted, - window, - cx, - ); - let second_elem = self.render_split_node( - second, - theme, - gap, - panel_bg, - border_color, - muted, - window, - cx, - ); - - // Use flex ratio to distribute space: first gets `ratio`, second gets `1 - ratio` - // Multiply by 1000 for precision in flex-grow values - let first_flex = *ratio * 1000.0; - let second_flex = (1.0 - *ratio) * 1000.0; - - // Horizontal: children are flex-col, container is flex-row - // Vertical: children are flex-row, container is flex-col - let is_horizontal = *direction == SplitDirection::Horizontal; - - let make_child_div = |elem: gpui::AnyElement, flex: f32| -> gpui::Div { - let mut d = div().flex().size_full(); - d = if is_horizontal { - d.flex_col() - } else { - d.flex_row() - }; - d.style().flex_grow = Some(flex); - d.style().flex_shrink = Some(1.0); - d.style().flex_basis = Some(relative(0.).into()); - d.child(elem) - }; - - let mut container = div().flex_1().flex().gap(px(gap)); - container = if is_horizontal { - container.flex_row() - } else { - container.flex_col() - }; - let container = container - .child(make_child_div(first_elem, first_flex)) - .child(make_child_div(second_elem, second_flex)); - - container.into_any_element() - } - } - } - - /// Render an empty slot in split tree mode. - fn render_split_empty_slot( - &mut self, - slot: SlotId, - panel_bg: gpui::Hsla, - border_color: gpui::Hsla, - muted: gpui::Hsla, - cx: &mut Context, - ) -> gpui::Stateful { - div() - .id(SharedString::from(format!("empty-slot-{}", slot.0))) - .size_full() - .bg(panel_bg) - .border_1() - .border_color(border_color) - .rounded_lg() - .border_dashed() - .flex() - .flex_col() - .items_center() - .justify_center() - .gap_2() - .cursor_pointer() - .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { - info!(?slot, "Empty split slot clicked — creating session"); - this.create_session_in_slot(slot, cx); - })) - .child( - div() - .text_xl() - .text_color(muted) - .font_family(icons::LUCIDE_FONT_FAMILY) - .child(icons::circle_plus()), - ) - .child( - div() - .text_xs() - .text_color(muted) - .child(super::types::EMPTY_CELL_MESSAGE), - ) - } - /// Render a session cell with terminal header and actual terminal content. - fn render_session_cell_with_terminal( + #[allow(clippy::too_many_arguments)] + pub(super) fn render_session_cell_with_terminal( &mut self, + pane_id: codirigent_core::PaneId, session_id: SessionId, hints: &TerminalHeaderRenderHints, theme: &CodirigentTheme, @@ -370,7 +181,7 @@ impl WorkspaceView { } if drag.source_index == drag_logical_index.unwrap_or(usize::MAX) { Some(DragVisual::Source) - } else if drag.target_index == drag_logical_index { + } else if drag.target.map(|target| target.index) == drag_logical_index { Some(DragVisual::Target) } else { None @@ -386,182 +197,23 @@ impl WorkspaceView { _ => cell_border, }; - let header_border = cell_border; - - // Color indicator bar - let color_indicator: gpui::Hsla = hints.color_indicator.into(); - let status_color: gpui::Hsla = hints.status.color.into(); - - let mut header = div() - .id(SharedString::from(format!( - "terminal-header-{}", - session_id.0 - ))) - .h(px(hints.height)) - .w_full() - .bg(panel_bg) - .border_b_1() - .border_color(header_border) - .flex() - .items_center() - .px_2() - .gap_2() - .child( - div() - .w(px(3.0)) - .h(px(16.0)) - .rounded_sm() - .bg(color_indicator), - ) - .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) - .child( - div() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(fg) - .overflow_hidden() - .text_ellipsis() - .child(hints.name.clone()), - ); - - // Project/directory name (after session name) - if let Some(project) = &hints.project_name { - header = header.child( - div() - .text_xs() - .text_color(muted.opacity(0.7)) - .overflow_hidden() - .text_ellipsis() - .child(project.clone()), - ); - } - - // Git branch badge (after session name) - if let Some(branch) = &hints.git_branch { - let git_fg = muted.opacity(0.8); - let git_badge_bg = border_color.opacity(0.25); - let branch_label = if branch.chars().count() > 16 { - let truncated: String = branch.chars().take(13).collect(); - format!("{}...", truncated) - } else { - branch.clone() - }; - let mut git_badge = div() - .px(px(4.0)) - .py_px() - .rounded_sm() - .bg(git_badge_bg) - .flex() - .flex_shrink_0() - .items_center() - .gap_1() - .child( - div() - .text_xs() - .text_color(git_fg) - .font_family(icons::LUCIDE_FONT_FAMILY) - .child(icons::git_branch()), - ) - .child(div().text_xs().text_color(git_fg).child(branch_label)); - - if let Some(count) = hints.git_dirty_count { - if count > 0 { - git_badge = git_badge.child( - div() - .text_xs() - .text_color(orange) - .child(format!("+{}", count)), - ); - } - } - - header = header.child(git_badge); - } - - header = header.child(div().flex_1()); - - // Task badge (if any) - if let Some(task) = &hints.task { - let task_bg: gpui::Hsla = task.bg_color.into(); - let task_color: gpui::Hsla = task.text_color.into(); - header = header.child( - div() - .px_2() - .py_px() - .rounded_sm() - .bg(task_bg) - .text_xs() - .text_color(task_color) - .overflow_hidden() - .text_ellipsis() - .child(task.display_text.clone()), - ); - } - - // Context usage (if any) - if let Some(context) = &hints.context { - let context_color: gpui::Hsla = context.color.into(); - header = header.child( - div() - .text_xs() - .text_color(context_color) - .child(context.text().to_string()), - ); - } - - // Set cursor for draggable header - header = if matches!(drag_visual, Some(DragVisual::Source)) { - header.cursor_grabbing() - } else if drag_logical_index.is_some() && self.cache.render_cell_info.len() > 1 { - header.cursor_grab() - } else { - header - }; + let header = self.render_pane_header( + pane_id.clone(), + session_id, + hints, + theme, + panel_bg, + border_color, + cell_border, + fg, + muted, + orange, + drag_logical_index, + matches!(drag_visual, Some(DragVisual::Source)), + cx, + ); - // --- Drag-and-drop handlers on header --- - // Use logical index (CellInfo.index) for swap operations, not Vec position. - if let Some(logical_index) = drag_logical_index { - // Don't allow drag in single-pane layout - if self.cache.render_cell_info.len() > 1 { - header = header - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, _window, cx| { - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - this.selection.drag = Some(super::types::DragState { - source_session_id: session_id, - source_index: logical_index, - start_position: pos, - current_position: pos, - active: false, - target_index: None, - }); - cx.notify(); - }), - ) - .on_mouse_move(cx.listener( - move |this, event: &MouseMoveEvent, _window, cx| { - let Some(drag) = &mut this.selection.drag else { - return; - }; - if drag.source_session_id != session_id { - return; - } - let pos = crate::layout::Point::new( - event.position.x.into(), - event.position.y.into(), - ); - drag.update_pointer(pos, &this.cache.render_cell_info); - cx.notify(); - }, - )); - // Note: mouse-up swap is handled by the global handler on - // workspace-container (gpui.rs) to catch releases anywhere. - } - } + // Mouse-up handling for active-tab drags lives on the workspace root. // Render terminal content before building the div tree so the // mutable borrow on `self` is released before `cx.listener()`. diff --git a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs index 6b59208a..ddf41075 100644 --- a/crates/codirigent-ui/src/workspace/impl_action_handlers.rs +++ b/crates/codirigent-ui/src/workspace/impl_action_handlers.rs @@ -72,17 +72,19 @@ impl WorkspaceView { cx: &mut Context, ) { info!("ClosePane action triggered"); - // Get the session in the focused slot BEFORE closing the pane - let session_to_close = self.workspace.focused_session_id(); + // Capture every tab in the focused pane before the layout removes it. + let sessions_to_close = self.workspace.focused_pane_session_ids(); if self.workspace.close_pane() { info!("Closed focused pane"); - // If the closed pane had a session, clean it up fully - if let Some(id) = session_to_close { - self.close_session(id, cx); - } else { + // Close every session that belonged to the removed pane. + if sessions_to_close.is_empty() { self.mark_layout_cache_dirty(); cx.notify(); + } else { + for id in sessions_to_close { + self.close_session(id, cx); + } } } } diff --git a/crates/codirigent-ui/src/workspace/impl_modals.rs b/crates/codirigent-ui/src/workspace/impl_modals.rs index d9e5c43b..93090fbe 100644 --- a/crates/codirigent-ui/src/workspace/impl_modals.rs +++ b/crates/codirigent-ui/src/workspace/impl_modals.rs @@ -6,8 +6,11 @@ //! - Modal keyboard input handling use super::gpui::WorkspaceView; -use super::types::{SessionActionKind, SessionActionModal, TaskCreationModal, GROUP_COLOR_PALETTE}; -use codirigent_core::{SessionId, SessionManager, Task, TaskId}; +use super::types::{ + SessionActionKind, SessionActionModal, SessionCreationModal, TaskCreationModal, + GROUP_COLOR_PALETTE, +}; +use codirigent_core::{PaneId, SessionId, SessionManager, Task, TaskId}; use gpui::{Context, KeyDownEvent}; use std::path::Path; use tracing::{info, warn}; @@ -43,6 +46,20 @@ impl WorkspaceView { self.modals.session_action = None; } + pub(super) fn open_session_creation_modal(&mut self, target_pane: Option) { + self.modals.session_creation = Some(SessionCreationModal { + target_pane, + shell_options: self.detected_shell_options(), + selected_shell_index: 0, + pending: false, + error: None, + }); + } + + pub(super) fn close_session_creation_modal(&mut self) { + self.modals.session_creation = None; + } + /// Pick the next unused group color from the palette. pub(super) fn next_group_color(&self) -> String { let used_colors: std::collections::HashSet<&str> = self @@ -278,6 +295,29 @@ impl WorkspaceView { cx.notify(); } + pub(super) fn apply_session_creation_modal(&mut self, cx: &mut Context) { + let Some(modal) = self.modals.session_creation.clone() else { + return; + }; + if modal.pending { + return; + } + + let requested_shell = modal + .shell_options + .get(modal.selected_shell_index) + .cloned() + .filter(|shell| !shell.is_empty()); + let target_pane = modal.target_pane.clone(); + + if let Some(active) = self.modals.session_creation.as_mut() { + active.pending = true; + active.error = None; + } + self.create_session_with_shell(target_pane, requested_shell, cx); + cx.notify(); + } + pub(super) fn handle_session_action_key_down( &mut self, event: &KeyDownEvent, @@ -339,6 +379,76 @@ impl WorkspaceView { true } + pub(super) fn handle_session_creation_key_down( + &mut self, + event: &KeyDownEvent, + cx: &mut Context, + ) -> bool { + let Some(modal) = self.modals.session_creation.as_mut() else { + return false; + }; + + let key = event.keystroke.key.to_lowercase(); + match key.as_str() { + "escape" => { + if modal.pending { + cx.notify(); + return true; + } + self.close_session_creation_modal(); + cx.notify(); + return true; + } + "enter" => { + self.apply_session_creation_modal(cx); + return true; + } + "up" | "left" | "k" => { + if modal.pending { + return true; + } + if !modal.shell_options.is_empty() { + modal.selected_shell_index = modal + .selected_shell_index + .checked_sub(1) + .unwrap_or(modal.shell_options.len().saturating_sub(1)); + cx.notify(); + } + return true; + } + "down" | "right" | "j" => { + if modal.pending { + return true; + } + if !modal.shell_options.is_empty() { + modal.selected_shell_index = + (modal.selected_shell_index + 1) % modal.shell_options.len(); + cx.notify(); + } + return true; + } + "tab" => { + if modal.pending { + return true; + } + if !modal.shell_options.is_empty() { + let len = modal.shell_options.len(); + let step = if event.keystroke.modifiers.shift { + len.saturating_sub(1) + } else { + 1 + }; + modal.selected_shell_index = (modal.selected_shell_index + step) % len; + cx.notify(); + } + return true; + } + _ => {} + } + + true + } + fn char_count(text: &str) -> usize { text.chars().count() } diff --git a/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs new file mode 100644 index 00000000..aacd70b0 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs @@ -0,0 +1,124 @@ +//! Workspace pointer interaction reducers. +//! +//! This module owns workspace-global pointer gesture coordination for pane drag +//! and split resize interactions. The top-level GPUI view wires mouse events +//! into these methods, while the gesture state transitions live here. + +use crate::workspace::gpui::WorkspaceView; +use gpui::{Context, MouseMoveEvent, MouseUpEvent}; + +impl WorkspaceView { + pub(super) fn handle_workspace_mouse_move( + &mut self, + event: &MouseMoveEvent, + cx: &mut Context, + ) { + let pos = crate::layout::Point::new(event.position.x.into(), event.position.y.into()); + + if self.selection.split_resize.is_some() { + if !event.dragging() { + self.finish_split_resize(cx); + cx.notify(); + return; + } + + if self.update_split_resize(pos) { + cx.notify(); + } + return; + } + + let Some(drag) = &mut self.selection.drag else { + return; + }; + + drag.update_pointer(pos, &self.cache.render_cell_info); + cx.notify(); + } + + pub(super) fn handle_workspace_mouse_up_left( + &mut self, + _event: &MouseUpEvent, + cx: &mut Context, + ) { + if self.selection.split_resize.is_some() { + self.finish_split_resize(cx); + cx.notify(); + return; + } + + self.finish_session_drag(cx); + } + + pub(super) fn update_split_resize(&mut self, position: crate::layout::Point) -> bool { + let Some(resize) = self.selection.split_resize.as_ref().copied() else { + return false; + }; + + let total = match resize.direction { + codirigent_core::SplitDirection::Horizontal => resize.bounds.size.width - resize.gap, + codirigent_core::SplitDirection::Vertical => resize.bounds.size.height - resize.gap, + }; + if total <= 0.0 { + return false; + } + + let offset = match resize.direction { + codirigent_core::SplitDirection::Horizontal => { + position.x - resize.bounds.origin.x - resize.grab_offset + } + codirigent_core::SplitDirection::Vertical => { + position.y - resize.bounds.origin.y - resize.grab_offset + } + }; + let ratio = offset / total; + let changed = + self.workspace + .resize_split_divider(resize.first_slot, resize.second_slot, ratio); + if changed { + if let Some(active_resize) = self.selection.split_resize.as_mut() { + active_resize.changed = true; + } + self.mark_layout_cache_dirty(); + } + changed + } + + fn finish_split_resize(&mut self, cx: &mut Context) { + if let Some(resize) = self.selection.split_resize.take() { + if resize.changed { + self.save_state_to_disk(cx); + } + } + } + + fn finish_session_drag(&mut self, cx: &mut Context) { + if let Some(drag) = self.selection.drag.take() { + if drag.active { + if let Some(target) = drag.target { + let changed = match target.kind { + super::types::DragTargetKind::PaneBody => self + .workspace + .swap_sessions(drag.source_index, target.index), + super::types::DragTargetKind::PaneHeader => self + .cache + .render_cell_info + .iter() + .find(|info| info.index == target.index) + .cloned() + .is_some_and(|info| { + self.workspace + .group_session_into_pane(drag.source_session_id, info.pane_id) + }), + }; + if changed { + self.mark_layout_cache_dirty(); + self.sync_layout_derived_state(); + self.save_state_to_disk(cx); + } + } + } + cx.notify(); + } + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs index 9896538b..ccf5336b 100644 --- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs +++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs @@ -8,13 +8,14 @@ use super::cli_helpers::is_safe_cli_session_id; use super::gpui::WorkspaceView; -use super::types::SESSION_NAME_PREFIX; +use super::types::{RestoreShellFallback, SESSION_NAME_PREFIX, SESSION_SHELL_AUTO_LABEL}; use crate::terminal::Terminal; use crate::terminal_header::TerminalHeader; use crate::terminal_view::TerminalView; use codirigent_core::{ - CodexExecutionMode, CodirigentEvent, EventBus, GridPosition, LayoutMode, ProcessMonitor, - Session, SessionId, SessionManager, SessionStatus, SlotId, + CodexExecutionMode, CodirigentEvent, EventBus, GridPosition, LayoutMode, PaneId, + PaneStackState, PaneTabGroup, ProcessMonitor, Session, SessionId, SessionManager, + SessionStatus, SlotId, }; use codirigent_session::DefaultSessionManager; use gpui::Context; @@ -27,10 +28,12 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; use tracing::{info, warn}; -#[derive(Debug)] +#[derive(Debug, Clone)] struct RestoreSessionPlan { + original_session_id: SessionId, session_name: String, working_dir: PathBuf, + shell: Option, group: Option, color: Option, claude_resume: Option, @@ -40,9 +43,10 @@ struct RestoreSessionPlan { gemini_resume: Option, } -#[derive(Debug)] +#[derive(Debug, Clone)] struct RestorePlan { layout: LayoutMode, + pane_stacks: Vec, sessions: Vec, } @@ -50,7 +54,9 @@ struct RestorePlan { struct SessionBootstrapRequest { session_name: String, working_dir: PathBuf, - shell: Option, + requested_shell: Option, + launch_shell: Option, + shell_warning: Option, } #[derive(Debug)] @@ -67,6 +73,58 @@ struct CompletedRestoreBootstrap { result: Result, } +fn legacy_pane_stacks_from_groups( + saved_sessions: &[Session], + pane_tab_groups: &[PaneTabGroup], +) -> Vec { + let mut groups = pane_tab_groups.to_vec(); + groups.sort_by_key(|group| match group.pane { + PaneId::GridCell { index } => (0u8, index), + PaneId::SplitSlot { slot } => (1u8, slot.0 as usize), + }); + + let valid_sessions: HashSet = + saved_sessions.iter().map(|session| session.id).collect(); + let mut assigned = HashSet::new(); + let mut stacks = Vec::new(); + + for group in groups { + let session_ids = group + .session_ids + .into_iter() + .filter(|session_id| { + valid_sessions.contains(session_id) && assigned.insert(*session_id) + }) + .collect::>(); + if session_ids.is_empty() { + continue; + } + + let active_session_id = if session_ids.contains(&group.active_session_id) { + group.active_session_id + } else { + session_ids[0] + }; + stacks.push(PaneStackState { + session_ids, + active_session_id, + }); + } + + stacks.extend( + saved_sessions + .iter() + .map(|session| session.id) + .filter(|session_id| !assigned.contains(session_id)) + .map(|session_id| PaneStackState { + session_ids: vec![session_id], + active_session_id: session_id, + }), + ); + + stacks +} + fn next_available_session_number( existing_sessions: &[Session], reserved_numbers: &HashSet, @@ -111,18 +169,19 @@ fn bootstrap_session( .create_session( request.session_name.clone(), request.working_dir.clone(), - request.shell.clone(), + request.launch_shell.clone(), ) .map_err(|error| error.to_string())?; let child_pid = manager.get_child_pid(session_id); - let session = manager.get_session(session_id).unwrap_or_else(|| { + let mut session = manager.get_session(session_id).unwrap_or_else(|| { Session::new( session_id, request.session_name.clone(), request.working_dir.clone(), ) }); + session.shell = request.requested_shell.clone(); Ok(SessionBootstrapResult { request, @@ -132,6 +191,37 @@ fn bootstrap_session( }) } +fn resolve_restore_shell_choice( + requested_shell: Option<&str>, + available_shells: &[String], + configured_shell: Option<&str>, +) -> (Option, Option) { + let requested_shell = requested_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()) + .map(str::to_string); + let configured_shell = configured_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()) + .map(str::to_string); + + match requested_shell { + Some(shell) if available_shells.iter().any(|candidate| candidate == &shell) => { + (Some(shell), None) + } + Some(shell) => (configured_shell, Some(shell)), + None => (configured_shell, None), + } +} + +fn restore_shell_fallback_message(fallback: &RestoreShellFallback) -> String { + format!( + "Requested shell '{}' was unavailable, so this session was opened with {}.", + fallback.requested_shell, + WorkspaceView::shell_display_label(fallback.effective_shell.as_deref()) + ) +} + fn layout_profile_for_restore(layout: &LayoutMode) -> Option { if let Some(profile) = crate::layout::LayoutProfile::from_mode(layout) { return Some(profile); @@ -547,8 +637,10 @@ mod tests { #[test] fn restore_resume_commands_preserve_cli_order() { let plan = RestoreSessionPlan { + original_session_id: SessionId(1), session_name: "Session 1".to_string(), working_dir: sample_working_dir(), + shell: None, group: None, color: None, claude_resume: Some("claude --resume abc\r".to_string()), @@ -575,7 +667,9 @@ mod tests { let request = SessionBootstrapRequest { session_name: "Session 1".to_string(), working_dir: temp.path().to_path_buf(), - shell: None, + requested_shell: None, + launch_shell: None, + shell_warning: None, }; let result = bootstrap_session(session_manager.clone(), request).unwrap(); @@ -594,6 +688,34 @@ mod tests { assert!(manager.get_session(result.session_id).is_some()); } + #[test] + fn bootstrap_session_preserves_requested_shell() { + let session_manager = create_test_session_manager(); + let temp = TempDir::new().unwrap(); + let shell = codirigent_session::detect_available_shells() + .into_iter() + .find(|shell| !shell.is_empty()) + .expect("at least one shell should be detected in test environments"); + let request = SessionBootstrapRequest { + session_name: "Session 1".to_string(), + working_dir: temp.path().to_path_buf(), + requested_shell: Some(shell.clone()), + launch_shell: Some(shell.clone()), + shell_warning: None, + }; + + let result = bootstrap_session(session_manager.clone(), request).unwrap(); + + assert_eq!(result.session.shell, Some(shell.clone())); + let manager = session_manager.lock().unwrap_or_else(|p| p.into_inner()); + assert_eq!( + manager + .get_session(result.session_id) + .and_then(|session| session.shell), + Some(shell) + ); + } + #[test] fn bootstrap_session_invalid_working_directory_returns_error_without_creating_session() { let session_manager = create_test_session_manager(); @@ -601,7 +723,9 @@ mod tests { let request = SessionBootstrapRequest { session_name: "Session 1".to_string(), working_dir: temp.path().join("missing-session-bootstrap"), - shell: None, + requested_shell: None, + launch_shell: None, + shell_warning: None, }; let result = bootstrap_session(session_manager.clone(), request); @@ -669,19 +793,20 @@ mod tests { #[test] fn build_restore_plan_preserves_saved_custom_grid_layout() { let fallback_dir = sample_working_dir(); + let mut session = Session::new(SessionId(1), "Session 1".to_string(), fallback_dir.clone()); + session.shell = Some("bash".to_string()); let state = AppState { - sessions: vec![Session::new( - SessionId(1), - "Session 1".to_string(), - fallback_dir.clone(), - )], + sessions: vec![session], layout: LayoutMode::Grid { rows: 1, cols: 4 }, + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: None, window_bounds: None, }; let plan = WorkspaceView::build_restore_plan(state, fallback_dir).unwrap(); assert_eq!(plan.layout, LayoutMode::Grid { rows: 1, cols: 4 }); + assert_eq!(plan.sessions[0].shell, Some("bash".to_string())); } #[test] @@ -702,6 +827,8 @@ mod tests { layout: LayoutMode::SplitTree { root: split_tree.clone(), }, + pane_tab_groups: Vec::new(), + pane_stacks: Vec::new(), updated_at: None, window_bounds: None, }; @@ -741,18 +868,184 @@ mod tests { Some(crate::layout::LayoutProfile::Custom { rows: 2, cols: 3 }) ); } + + #[test] + fn resolve_restore_shell_choice_uses_requested_shell_when_available() { + let available_shells = vec!["".to_string(), "bash".to_string(), "zsh".to_string()]; + assert_eq!( + resolve_restore_shell_choice(Some("zsh"), &available_shells, Some("bash")), + (Some("zsh".to_string()), None) + ); + } + + #[test] + fn resolve_restore_shell_choice_falls_back_to_auto_when_requested_shell_missing() { + let available_shells = vec!["".to_string(), "bash".to_string(), "zsh".to_string()]; + assert_eq!( + resolve_restore_shell_choice(Some("pwsh"), &available_shells, Some("bash")), + (Some("bash".to_string()), Some("pwsh".to_string())) + ); + } + + #[test] + fn resolve_restore_shell_choice_preserves_auto_behavior_for_configured_default_shell() { + let available_shells = vec!["".to_string(), "bash".to_string()]; + assert_eq!( + resolve_restore_shell_choice(Some("pwsh"), &available_shells, Some("missing")), + (Some("missing".to_string()), Some("pwsh".to_string())) + ); + assert_eq!( + resolve_restore_shell_choice(None, &available_shells, Some("missing")), + (Some("missing".to_string()), None) + ); + } + + #[test] + fn restore_shell_fallback_message_uses_effective_shell_label() { + let fallback = RestoreShellFallback { + requested_shell: "pwsh".to_string(), + effective_shell: Some("bash".to_string()), + }; + + assert_eq!( + restore_shell_fallback_message(&fallback), + "Requested shell 'pwsh' was unavailable, so this session was opened with bash." + ); + } + + #[test] + fn restore_shell_fallback_message_uses_auto_label_without_effective_shell() { + let fallback = RestoreShellFallback { + requested_shell: "pwsh".to_string(), + effective_shell: None, + }; + + assert_eq!( + restore_shell_fallback_message(&fallback), + "Requested shell 'pwsh' was unavailable, so this session was opened with Auto." + ); + } } impl WorkspaceView { + pub(super) fn detected_shell_options(&self) -> Vec { + let mut shells = self + .cache + .detected_shells + .clone() + .unwrap_or_else(codirigent_session::detect_available_shells); + shells.retain(|shell| !shell.trim().is_empty()); + shells.sort(); + shells.dedup(); + shells.insert(0, String::new()); + shells + } + + fn configured_shell(&self) -> Option { + let shell = self + .effective_user_settings() + .general + .default_shell + .trim() + .to_string(); + (!shell.is_empty()).then_some(shell) + } + + fn auto_launch_shell(&self) -> Option { + self.configured_shell() + } + + fn launch_shell_for_requested_shell(&self, requested_shell: Option<&str>) -> Option { + requested_shell + .filter(|shell| !shell.is_empty()) + .map(str::to_string) + .or_else(|| self.auto_launch_shell()) + } + + fn restore_launch_shell( + &self, + requested_shell: Option<&str>, + ) -> (Option, Option) { + resolve_restore_shell_choice( + requested_shell, + &self.detected_shell_options(), + self.configured_shell().as_deref(), + ) + } + + pub(super) fn shell_display_label(shell: Option<&str>) -> String { + shell + .filter(|value| !value.is_empty()) + .unwrap_or(SESSION_SHELL_AUTO_LABEL) + .to_string() + } + + pub(super) fn session_shell_warning_message(&self, session_id: SessionId) -> Option { + self.cache + .restore_shell_fallbacks + .get(&session_id) + .map(restore_shell_fallback_message) + } + + pub(super) fn session_shell_display( + &self, + session_id: SessionId, + requested_shell: Option<&str>, + ) -> (String, Option) { + if let Some(fallback) = self.cache.restore_shell_fallbacks.get(&session_id) { + ( + Self::shell_display_label(fallback.effective_shell.as_deref()), + Some(restore_shell_fallback_message(fallback)), + ) + } else { + ( + Self::shell_display_label(requested_shell), + self.session_shell_warning_message(session_id), + ) + } + } + + fn sync_manager_session_shell( + &mut self, + session_id: SessionId, + requested_shell: Option, + ) { + if let Ok(manager) = self.session_manager.lock() { + let requested_shell = requested_shell.clone(); + manager.with_session_state_mut(session_id, move |state| { + state.session.shell = requested_shell.clone(); + }); + } + } + + fn record_restored_shell_warning( + &mut self, + session_id: SessionId, + shell_warning: Option, + effective_shell: Option, + ) { + if let Some(requested_shell) = shell_warning { + self.cache.restore_shell_fallbacks.insert( + session_id, + RestoreShellFallback { + requested_shell, + effective_shell, + }, + ); + } else { + self.cache.restore_shell_fallbacks.remove(&session_id); + } + } + fn release_session_create_reservation( &mut self, reserved_number: u64, - target_slot: Option, + target_pane: Option, ) { self.polling .pending_session_bootstrap_numbers .remove(&reserved_number); - if let Some(slot) = target_slot { + if let Some(PaneId::SplitSlot { slot }) = target_pane { self.polling.pending_session_bootstrap_slots.remove(&slot); } } @@ -777,6 +1070,9 @@ impl WorkspaceView { .and_then(|name| name.to_str()) .unwrap_or("unknown"); header = header.with_project_name(dir_name); + let (shell_label, shell_warning) = + self.session_shell_display(session.id, session.shell.as_deref()); + header = header.with_shell(shell_label, shell_warning); if let Some(group) = group { header.group_name = Some(group.clone()); @@ -801,6 +1097,7 @@ impl WorkspaceView { self.terminals.remove(&session_id); self.pty_write_receivers.remove(&session_id); self.terminal_headers.remove(&session_id); + self.cache.restore_shell_fallbacks.remove(&session_id); self.output_dispatcher.remove_session(session_id); self.polling.output_prepare_in_flight.remove(&session_id); self.with_detector(|detector| detector.stop_monitoring(session_id)); @@ -819,22 +1116,25 @@ impl WorkspaceView { &mut self, session: Session, session_name: &str, - target_slot: Option, + target_pane: Option, group: Option<&String>, color: Option<&String>, ) -> bool { let session_id = session.id; self.create_terminal_view_for_session(session_id); - let added = match target_slot { - Some(slot) => { - if self.workspace.add_session_to_slot(session.clone(), slot) { + let added = match target_pane.clone() { + Some(pane_id) => { + if self + .workspace + .add_session_to_pane(session.clone(), pane_id.clone()) + { true } else { warn!( ?session_id, - ?slot, - "Reserved slot unavailable when session bootstrap completed; falling back" + ?pane_id, + "Reserved pane unavailable when session bootstrap completed; falling back" ); self.workspace.add_session(session.clone()) } @@ -880,15 +1180,27 @@ impl WorkspaceView { fn finalize_created_session_bootstrap( &mut self, bootstrapped: SessionBootstrapResult, - target_slot: Option, + target_pane: Option, cx: &mut Context, ) { self.start_bootstrapped_session_monitoring(bootstrapped.session_id, bootstrapped.child_pid); + self.sync_manager_session_shell( + bootstrapped.session_id, + bootstrapped.request.requested_shell.clone(), + ); + self.record_restored_shell_warning( + bootstrapped.session_id, + bootstrapped.request.shell_warning.clone(), + bootstrapped.request.launch_shell.clone(), + ); + + let mut session = bootstrapped.session.clone(); + session.shell = bootstrapped.request.requested_shell.clone(); if !self.attach_bootstrapped_session( - bootstrapped.session.clone(), + session, &bootstrapped.request.session_name, - target_slot, + target_pane.clone(), None, None, ) { @@ -897,11 +1209,11 @@ impl WorkspaceView { self.select_session_with_cx(bootstrapped.session_id, cx); - if let Some(slot) = target_slot { + if let Some(pane_id) = target_pane { info!( name = %bootstrapped.request.session_name, - ?slot, - "Created new session in slot via background bootstrap" + ?pane_id, + "Created new session in pane via background bootstrap" ); } else { info!( @@ -921,6 +1233,15 @@ impl WorkspaceView { plan: RestoreSessionPlan, ) { self.start_bootstrapped_session_monitoring(bootstrapped.session_id, bootstrapped.child_pid); + self.sync_manager_session_shell( + bootstrapped.session_id, + bootstrapped.request.requested_shell.clone(), + ); + self.record_restored_shell_warning( + bootstrapped.session_id, + bootstrapped.request.shell_warning.clone(), + bootstrapped.request.launch_shell.clone(), + ); if plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some() { let codex_execution_mode = plan.codex_execution_mode; @@ -934,6 +1255,7 @@ impl WorkspaceView { } let mut session = bootstrapped.session; + session.shell = bootstrapped.request.requested_shell.clone(); session.group = plan.group.clone(); session.color = plan.color.clone(); session.codex_execution_mode = plan.codex_execution_mode; @@ -977,7 +1299,7 @@ impl WorkspaceView { &mut self, request: SessionBootstrapRequest, reserved_number: u64, - target_slot: Option, + target_pane: Option, cx: &mut Context, ) { let session_manager = self.session_manager.clone(); @@ -990,17 +1312,27 @@ impl WorkspaceView { .await; let _ = this.update(cx, |this, cx| { - this.release_session_create_reservation(reserved_number, target_slot); + this.release_session_create_reservation(reserved_number, target_pane.clone()); match result { Ok(bootstrapped) => { - this.finalize_created_session_bootstrap(bootstrapped, target_slot, cx); + this.close_session_creation_modal(); + this.finalize_created_session_bootstrap( + bootstrapped, + target_pane.clone(), + cx, + ); } Err(error) => { + if let Some(modal) = this.modals.session_creation.as_mut() { + modal.pending = false; + modal.error = Some(format!("Failed to create session: {}", error)); + } warn!( name = %session_name, %error, "Failed to create session via background bootstrap" ); + cx.notify(); } } }); @@ -1015,6 +1347,8 @@ impl WorkspaceView { let codirigent_core::AppState { sessions: saved_sessions, layout, + pane_stacks, + pane_tab_groups, .. } = state; @@ -1022,6 +1356,12 @@ impl WorkspaceView { return None; } + let pane_stacks = if pane_stacks.is_empty() { + legacy_pane_stacks_from_groups(&saved_sessions, &pane_tab_groups) + } else { + pane_stacks + }; + let mut used_names = std::collections::HashSet::new(); let mut used_claude_ids: std::collections::HashSet = std::collections::HashSet::new(); @@ -1095,8 +1435,10 @@ impl WorkspaceView { .and_then(|gemini_id| build_resume_command("gemini", gemini_id, &[])); sessions.push(RestoreSessionPlan { + original_session_id: saved.id, session_name, working_dir, + shell: saved.shell, group: saved.group, color: saved.color, claude_resume, @@ -1107,15 +1449,14 @@ impl WorkspaceView { }); } - Some(RestorePlan { layout, sessions }) + Some(RestorePlan { + layout, + pane_stacks, + sessions, + }) } - fn apply_restore_plan( - &mut self, - plan: RestorePlan, - shell: Option, - cx: &mut Context, - ) { + fn apply_restore_plan(&mut self, plan: RestorePlan, cx: &mut Context) { if plan.sessions.is_empty() { self.polling.restore_in_flight = false; return; @@ -1125,18 +1466,44 @@ impl WorkspaceView { let session_count = plan.sessions.len(); let desired_layout = plan.layout.clone(); + let desired_pane_stacks = plan.pane_stacks.clone(); let staging_layout = staging_layout_for_restore(&desired_layout, session_count); let reapply_saved_layout = staging_layout != desired_layout; + let restore_batches = { + let mut remaining = plan.sessions.into_iter(); + let mut batches = Vec::new(); + loop { + let batch = remaining + .by_ref() + .take(2) + .map(|plan| { + let (launch_shell, shell_warning) = + self.restore_launch_shell(plan.shell.as_deref()); + let request = SessionBootstrapRequest { + session_name: plan.session_name.clone(), + working_dir: plan.working_dir.clone(), + requested_shell: plan.shell.clone(), + launch_shell, + shell_warning, + }; + (plan, request) + }) + .collect::>(); + if batch.is_empty() { + break; + } + batches.push(batch); + } + batches + }; self.apply_restored_layout_mode(&staging_layout); self.polling.restore_in_flight = true; - let restore_sessions = plan.sessions; let session_manager = self.session_manager.clone(); cx.spawn(async move |this: gpui::WeakEntity, cx| { - let mut remaining = restore_sessions.into_iter().peekable(); - while remaining.peek().is_some() { - let batch = remaining.by_ref().take(2).collect::>(); - let is_last_batch = remaining.peek().is_none(); - let shell = shell.clone(); + let mut restored_session_ids = std::collections::HashMap::new(); + let total_batches = restore_batches.len(); + for (batch_index, batch) in restore_batches.into_iter().enumerate() { + let is_last_batch = batch_index + 1 == total_batches; let desired_layout = desired_layout.clone(); let session_manager = session_manager.clone(); @@ -1145,16 +1512,9 @@ impl WorkspaceView { .spawn(async move { batch .into_iter() - .map(|plan| { - let request = SessionBootstrapRequest { - session_name: plan.session_name.clone(), - working_dir: plan.working_dir.clone(), - shell: shell.clone(), - }; - CompletedRestoreBootstrap { - plan, - result: bootstrap_session(session_manager.clone(), request), - } + .map(|(plan, request)| CompletedRestoreBootstrap { + plan, + result: bootstrap_session(session_manager.clone(), request), }) .collect::>() }) @@ -1164,10 +1524,14 @@ impl WorkspaceView { for completion in completions { match completion.result { Ok(bootstrapped) => { + let restored_session_id = bootstrapped.session_id; + let original_session_id = completion.plan.original_session_id; this.finalize_restored_session_bootstrap( bootstrapped, completion.plan, ); + restored_session_ids + .insert(original_session_id, restored_session_id); } Err(error) => { warn!( @@ -1183,8 +1547,13 @@ impl WorkspaceView { if reapply_saved_layout { this.apply_restored_layout_mode(&desired_layout); } - if let Some(first_id) = this.workspace.sessions().first().map(|s| s.id) { - this.select_session_with_cx(first_id, cx); + this.workspace + .restore_pane_stacks(&desired_pane_stacks, &restored_session_ids); + if let Some(focused_id) = this.workspace.focused_session_id() { + this.selection.selected_session_id = Some(focused_id); + this.drawer.set_selected_session(Some(focused_id)); + this.sync_layout_derived_state(); + this.sync_file_tree_to_focused_session(cx); } this.polling.restore_in_flight = false; info!("Session restoration complete"); @@ -1234,20 +1603,30 @@ impl WorkspaceView { /// Create a new terminal session in the focused pane. pub fn create_session(&mut self, cx: &mut Context) { - self.create_session_inner(None, cx); + self.open_session_creation_modal(None); + cx.notify(); + } + + /// Create a new session in a specific visible pane. + pub fn create_session_in_pane(&mut self, pane_id: PaneId, cx: &mut Context) { + self.open_session_creation_modal(Some(pane_id)); + cx.notify(); } /// Create a new session in a specific split tree slot. pub fn create_session_in_slot(&mut self, slot: SlotId, cx: &mut Context) { - self.create_session_inner(Some(slot), cx); + self.open_session_creation_modal(Some(PaneId::SplitSlot { slot })); + cx.notify(); } - /// Shared implementation for session creation. - /// When `target_slot` is `None`, adds to the first available slot; - /// when `Some(slot)`, adds to that specific slot. - fn create_session_inner(&mut self, target_slot: Option, cx: &mut Context) { + pub(super) fn create_session_with_shell( + &mut self, + target_pane: Option, + requested_shell: Option, + cx: &mut Context, + ) { // Find the lowest available session number (reuse gaps from closed sessions) - if let Some(slot) = target_slot { + if let Some(PaneId::SplitSlot { slot }) = target_pane { if self.polling.pending_session_bootstrap_slots.contains(&slot) { warn!(?slot, "Session creation already pending for slot"); return; @@ -1273,13 +1652,14 @@ impl WorkspaceView { .or_else(|| std::env::current_dir().ok()) .unwrap_or_else(std::env::temp_dir); - let shell = self.configured_shell(); let request = SessionBootstrapRequest { session_name: name, working_dir, - shell, + launch_shell: self.launch_shell_for_requested_shell(requested_shell.as_deref()), + requested_shell, + shell_warning: None, }; - self.spawn_create_session_bootstrap(request, num, target_slot, cx); + self.spawn_create_session_bootstrap(request, num, target_pane, cx); } /// Restore sessions from disk on startup without blocking the UI thread. @@ -1290,7 +1670,6 @@ impl WorkspaceView { self.polling.restore_in_flight = true; let storage = self.persistence.storage.clone(); - let shell = self.configured_shell(); let fallback_dir = self .project .project_root @@ -1315,7 +1694,7 @@ impl WorkspaceView { let _ = this.update(cx, |this, cx| { if let Some(plan) = restore_plan { - this.apply_restore_plan(plan, shell.clone(), cx); + this.apply_restore_plan(plan, cx); } else { this.polling.restore_in_flight = false; } @@ -1324,16 +1703,6 @@ impl WorkspaceView { .detach(); } - /// Return the configured shell, or `None` to use the system default. - fn configured_shell(&self) -> Option { - let shell = self.effective_user_settings().general.default_shell.clone(); - if shell.is_empty() { - None - } else { - Some(shell) - } - } - /// Close the focused session. pub fn close_focused_session(&mut self, cx: &mut Context) { if let Some(id) = self.workspace.focused_session_id() { @@ -1368,6 +1737,7 @@ impl WorkspaceView { readers.cached_status.remove(&id); } self.polling.shell_input_buffers.remove(&id); + self.cache.restore_shell_fallbacks.remove(&id); // Remove from output dispatcher tracking (ready/in-flight sets) self.output_dispatcher.remove_session(id); diff --git a/crates/codirigent-ui/src/workspace/mod.rs b/crates/codirigent-ui/src/workspace/mod.rs index ce9f0659..e136dc4a 100644 --- a/crates/codirigent-ui/src/workspace/mod.rs +++ b/crates/codirigent-ui/src/workspace/mod.rs @@ -77,6 +77,9 @@ mod impl_settings; #[cfg(feature = "gpui-full")] mod impl_ui_operations; +#[cfg(feature = "gpui-full")] +mod impl_pointer_interactions; + #[cfg(feature = "gpui-full")] pub(crate) mod render; @@ -104,6 +107,12 @@ mod modal_render; #[cfg(feature = "gpui-full")] mod grid_render; +#[cfg(feature = "gpui-full")] +mod split_render; + +#[cfg(feature = "gpui-full")] +mod pane_header_render; + #[cfg(feature = "gpui-full")] mod settings_panels; diff --git a/crates/codirigent-ui/src/workspace/modal_render.rs b/crates/codirigent-ui/src/workspace/modal_render.rs index 1d1665ec..cd65a79f 100644 --- a/crates/codirigent-ui/src/workspace/modal_render.rs +++ b/crates/codirigent-ui/src/workspace/modal_render.rs @@ -919,4 +919,290 @@ impl WorkspaceView { ), ) } + + /// Render the session creation modal with per-session shell selection. + pub(super) fn render_session_creation_modal( + &mut self, + cx: &mut Context, + ) -> Option { + let modal = self.modals.session_creation.clone()?; + + let theme = self.workspace().theme(); + let panel_bg: gpui::Hsla = theme.panel_background.into(); + let border_color: gpui::Hsla = theme.border.into(); + let fg: gpui::Hsla = theme.foreground.into(); + let muted: gpui::Hsla = theme.muted.into(); + let primary: gpui::Hsla = theme.primary.into(); + let warning: gpui::Hsla = theme.orange.into(); + let row_hover: gpui::Hsla = theme.hover.into(); + let error_color: gpui::Hsla = gpui::Hsla::red(); + let modal_pending = modal.pending; + + Some( + div() + .id("session-create-overlay") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::Hsla::black().opacity(0.5)) + .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { + if !modal_pending { + this.close_session_creation_modal(); + cx.notify(); + } + })) + .child( + div() + .id("session-create-modal") + .w(px(460.0)) + .bg(panel_bg) + .border_1() + .border_color(border_color) + .rounded_lg() + .flex() + .flex_col() + .on_click(cx.listener(|_this, _: &ClickEvent, _window, cx| { + cx.stop_propagation(); + })) + .child( + div() + .h(px(48.0)) + .px_4() + .border_b_1() + .border_color(border_color) + .flex() + .items_center() + .child(self.aligned_icon_label_row_with_offset( + icons::terminal(), + fg, + 16.0, + "Create Session", + fg, + 16.0, + FontWeight::SEMIBOLD, + 20.0, + 8.0, + 3.0, + )), + ) + .child( + div() + .p_4() + .flex() + .flex_col() + .gap_3() + .child( + div() + .text_sm() + .text_color(muted) + .child("Choose which shell to use for this session."), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(fg) + .child("Shell"), + ) + .child({ + let mut list = div().flex().flex_col().gap_2().max_h(px(220.0)); + + for (index, option) in modal.shell_options.iter().enumerate() { + let is_selected = index == modal.selected_shell_index; + let option_label = + WorkspaceView::shell_display_label(Some(option)); + let option_hint = if option.is_empty() { + "Use the default shell setting or the platform default." + } else { + "Open the session in this shell." + }; + let option_border = + if is_selected { primary } else { border_color }; + let option_bg = if is_selected { + primary.opacity(0.12) + } else { + gpui::Hsla::transparent_black() + }; + + list = list.child( + div() + .id(SharedString::from(format!( + "session-shell-option-{}", + index + ))) + .w_full() + .p_3() + .border_1() + .border_color(option_border) + .rounded_md() + .bg(option_bg) + .cursor_pointer() + .hover(|style| style.bg(row_hover)) + .on_click(cx.listener({ + move |this, _: &ClickEvent, _window, cx| { + if modal_pending { + return; + } + if let Some(active) = + this.modals.session_creation.as_mut() + { + active.selected_shell_index = index; + active.error = None; + } + cx.notify(); + } + })) + .child( + div() + .flex() + .items_start() + .gap_3() + .child( + div() + .mt_px() + .w(px(14.0)) + .h(px(14.0)) + .rounded_full() + .border_1() + .border_color(if is_selected { + primary + } else { + muted + }) + .bg(if is_selected { + primary + } else { + gpui::Hsla::transparent_black() + }), + ) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight( + FontWeight::MEDIUM, + ) + .text_color(fg) + .child(option_label), + ) + .child( + div() + .text_xs() + .text_color( + if option.is_empty() { + warning.opacity(0.9) + } else { + muted + }, + ) + .child(option_hint), + ), + ), + ), + ); + } + + list + }) + .when_some(modal.error.clone(), |this, error| { + this.child(div().text_sm().text_color(error_color).child(error)) + }), + ) + .child( + div() + .h(px(60.0)) + .px_4() + .border_t_1() + .border_color(border_color) + .flex() + .items_center() + .justify_end() + .gap_2() + .child( + div() + .id("session-create-cancel") + .px_4() + .py_2() + .border_1() + .border_color(border_color) + .rounded_md() + .text_sm() + .text_color(fg) + .when(!modal_pending, |this| this.cursor_pointer()) + .when(!modal_pending, |this| { + this.hover(|style| style.bg(border_color.opacity(0.1))) + }) + .on_click(cx.listener( + move |this, _: &ClickEvent, _window, cx| { + if !modal_pending { + this.close_session_creation_modal(); + cx.notify(); + } + }, + )) + .child(self.aligned_icon_label_row_with_offset( + icons::x(), + fg, + 12.0, + "Cancel", + fg, + 14.0, + FontWeight::MEDIUM, + 16.0, + 4.0, + 3.0, + )), + ) + .child( + div() + .id("session-create-apply") + .px_4() + .py_2() + .bg(if modal_pending { + primary.opacity(0.6) + } else { + primary + }) + .rounded_md() + .text_sm() + .text_color(gpui::Hsla::white()) + .when(!modal_pending, |this| this.cursor_pointer()) + .when(!modal_pending, |this| { + this.hover(|style| style.bg(primary.opacity(0.8))) + }) + .on_click(cx.listener( + move |this, _: &ClickEvent, _window, cx| { + if !modal_pending { + this.apply_session_creation_modal(cx); + } + }, + )) + .child(self.aligned_icon_label_row_with_offset( + icons::plus(), + gpui::Hsla::white(), + 12.0, + if modal_pending { + "Creating..." + } else { + "Create" + }, + gpui::Hsla::white(), + 14.0, + FontWeight::MEDIUM, + 16.0, + 4.0, + 3.0, + )), + ), + ), + ), + ) + } } diff --git a/crates/codirigent-ui/src/workspace/pane_header_render.rs b/crates/codirigent-ui/src/workspace/pane_header_render.rs new file mode 100644 index 00000000..7d72da23 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/pane_header_render.rs @@ -0,0 +1,397 @@ +//! Pane header and tab-strip rendering. +//! +//! This module owns the header UI for workspace panes, including pane-local +//! tabs, session badges, and the pane-local session creation affordance. + +use crate::icons; +use crate::terminal_header::TerminalHeaderRenderHints; +use crate::theme::CodirigentTheme; +use crate::workspace::gpui::WorkspaceView; +use codirigent_core::{PaneId, SessionId}; +use gpui::{ + div, px, ClickEvent, Context, FontWeight, InteractiveElement, MouseButton, MouseDownEvent, + MouseMoveEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, +}; + +impl WorkspaceView { + #[allow(clippy::too_many_arguments)] + pub(super) fn render_pane_header( + &mut self, + pane_id: PaneId, + session_id: SessionId, + hints: &TerminalHeaderRenderHints, + theme: &CodirigentTheme, + panel_bg: gpui::Hsla, + border_color: gpui::Hsla, + header_border: gpui::Hsla, + fg: gpui::Hsla, + muted: gpui::Hsla, + orange: gpui::Hsla, + drag_logical_index: Option, + is_drag_source: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let color_indicator: gpui::Hsla = hints.color_indicator.into(); + let status_color: gpui::Hsla = hints.status.color.into(); + let show_plus_button = self + .workspace() + .pane_active_session_id(pane_id.clone()) + .is_some(); + + let mut header = div() + .id(SharedString::from(format!( + "terminal-header-{}", + session_id.0 + ))) + .h(px(hints.height)) + .w_full() + .bg(panel_bg) + .border_b_1() + .border_color(header_border) + .flex() + .items_center() + .px_2() + .gap_2() + .child( + div() + .w(px(3.0)) + .h(px(16.0)) + .rounded_sm() + .bg(color_indicator), + ) + .child(div().w(px(8.0)).h(px(8.0)).rounded_full().bg(status_color)) + .child(self.render_pane_tab_strip( + pane_id.clone(), + session_id, + hints, + theme, + border_color, + fg, + muted, + drag_logical_index, + cx, + )); + + if let Some(project) = &hints.project_name { + header = header.child( + div() + .text_xs() + .text_color(muted.opacity(0.7)) + .overflow_hidden() + .text_ellipsis() + .child(project.clone()), + ); + } + + if let Some(branch) = &hints.git_branch { + header = header.child(self.render_git_branch_badge( + branch, + hints, + border_color, + muted, + orange, + )); + } + + if let Some(shell_label) = &hints.shell_label { + header = header.child(self.render_shell_badge( + shell_label, + hints, + border_color, + muted, + orange, + )); + } + + header = header.child(div().flex_1()); + + if let Some(task) = &hints.task { + let task_bg: gpui::Hsla = task.bg_color.into(); + let task_color: gpui::Hsla = task.text_color.into(); + header = header.child( + div() + .px_2() + .py_px() + .rounded_sm() + .bg(task_bg) + .text_xs() + .text_color(task_color) + .overflow_hidden() + .text_ellipsis() + .child(task.display_text.clone()), + ); + } + + if let Some(context) = &hints.context { + let context_color: gpui::Hsla = context.color.into(); + header = header.child( + div() + .text_xs() + .text_color(context_color) + .child(context.text().to_string()), + ); + } + + if show_plus_button { + header = header.child(self.render_pane_add_button( + &pane_id, + session_id, + border_color, + fg, + cx, + )); + } + + if is_drag_source { + header.cursor_grabbing() + } else { + header + } + } + + #[allow(clippy::too_many_arguments)] + fn render_pane_tab_strip( + &mut self, + pane_id: PaneId, + session_id: SessionId, + hints: &TerminalHeaderRenderHints, + theme: &CodirigentTheme, + border_color: gpui::Hsla, + fg: gpui::Hsla, + muted: gpui::Hsla, + drag_logical_index: Option, + cx: &mut Context, + ) -> gpui::Div { + let pane_tab_ids = self.workspace().pane_tab_session_ids(pane_id.clone()); + let mut tab_strip = div().flex().items_center().gap_1().overflow_hidden(); + + for tab_session_id in pane_tab_ids { + let tab_is_active = tab_session_id == session_id; + let tab_name = self + .workspace() + .session(tab_session_id) + .map(|session| session.name.clone()) + .unwrap_or_else(|| hints.name.clone()); + let tab_bg = if tab_is_active { + theme.active.into() + } else { + border_color.opacity(0.35) + }; + let tab_fg = if tab_is_active { + fg + } else { + muted.opacity(0.9) + }; + + let mut tab = div() + .id(SharedString::from(format!( + "terminal-tab-{}-{}", + session_id.0, tab_session_id.0 + ))) + .px_2() + .h(px(22.0)) + .rounded_md() + .bg(tab_bg) + .flex() + .items_center() + .gap_1() + .overflow_hidden() + .cursor_pointer() + .on_click(cx.listener({ + let pane_id = pane_id.clone(); + move |this, _: &ClickEvent, _window, cx| { + if this + .workspace + .activate_pane_tab(pane_id.clone(), tab_session_id) + { + this.select_session_with_cx(tab_session_id, cx); + this.mark_layout_cache_dirty(); + this.sync_layout_derived_state(); + this.save_state_to_disk(cx); + cx.notify(); + } + } + })) + .child( + div() + .text_xs() + .font_weight(if tab_is_active { + FontWeight::SEMIBOLD + } else { + FontWeight::MEDIUM + }) + .text_color(tab_fg) + .overflow_hidden() + .text_ellipsis() + .child(tab_name), + ); + + if tab_is_active { + tab = tab + .cursor_grab() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + this.selection.drag = Some(super::types::DragState { + source_session_id: tab_session_id, + source_index: drag_logical_index.unwrap_or(0), + start_position: pos, + current_position: pos, + active: false, + target: None, + }); + cx.notify(); + }), + ) + .on_mouse_move(cx.listener( + move |this, event: &MouseMoveEvent, _window, cx| { + let Some(drag) = &mut this.selection.drag else { + return; + }; + if drag.source_session_id != tab_session_id { + return; + } + let pos = crate::layout::Point::new( + event.position.x.into(), + event.position.y.into(), + ); + drag.update_pointer(pos, &this.cache.render_cell_info); + cx.notify(); + }, + )); + } + + tab_strip = tab_strip.child(tab); + } + + tab_strip + } + + fn render_git_branch_badge( + &mut self, + branch: &str, + hints: &TerminalHeaderRenderHints, + border_color: gpui::Hsla, + muted: gpui::Hsla, + orange: gpui::Hsla, + ) -> gpui::Div { + let git_fg = muted.opacity(0.8); + let git_badge_bg = border_color.opacity(0.25); + let branch_label = if branch.chars().count() > 16 { + let truncated: String = branch.chars().take(13).collect(); + format!("{}...", truncated) + } else { + branch.to_owned() + }; + let mut git_badge = div() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(git_badge_bg) + .flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child( + div() + .text_xs() + .text_color(git_fg) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::git_branch()), + ) + .child(div().text_xs().text_color(git_fg).child(branch_label)); + + if let Some(count) = hints.git_dirty_count.filter(|count| *count > 0) { + git_badge = git_badge.child( + div() + .text_xs() + .text_color(orange) + .child(format!("+{}", count)), + ); + } + + git_badge + } + + fn render_shell_badge( + &mut self, + shell_label: &str, + hints: &TerminalHeaderRenderHints, + border_color: gpui::Hsla, + muted: gpui::Hsla, + orange: gpui::Hsla, + ) -> gpui::Div { + let shell_warning = hints.shell_warning.is_some(); + let shell_fg = if shell_warning { + orange + } else { + muted.opacity(0.8) + }; + let shell_bg = if shell_warning { + orange.opacity(0.12) + } else { + border_color.opacity(0.25) + }; + + div() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(shell_bg) + .flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child( + div() + .text_xs() + .text_color(shell_fg) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::terminal()), + ) + .child( + div() + .text_xs() + .text_color(shell_fg) + .child(shell_label.to_owned()), + ) + } + + fn render_pane_add_button( + &mut self, + pane_id: &PaneId, + session_id: SessionId, + border_color: gpui::Hsla, + fg: gpui::Hsla, + cx: &mut Context, + ) -> gpui::Stateful { + div() + .id(SharedString::from(format!("pane-add-tab-{}", session_id.0))) + .w(px(20.0)) + .h(px(20.0)) + .rounded_md() + .bg(border_color.opacity(0.25)) + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|style| style.bg(border_color.opacity(0.45))) + .on_click(cx.listener({ + let pane_id = pane_id.clone(); + move |this, _: &ClickEvent, _window, cx| { + this.create_session_in_pane(pane_id.clone(), cx); + } + })) + .child( + div() + .text_xs() + .font_family(icons::LUCIDE_FONT_FAMILY) + .text_color(fg) + .child(icons::plus()), + ) + } +} diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs index 01e10506..cf6499e2 100644 --- a/crates/codirigent-ui/src/workspace/render.rs +++ b/crates/codirigent-ui/src/workspace/render.rs @@ -19,8 +19,9 @@ use super::gpui::WorkspaceView; use crate::icons; use crate::title_bar::TitleBar; use gpui::{ - div, px, ClickEvent, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, - SharedString, StatefulInteractiveElement, Styled, Window, WindowControlArea, + div, prelude::FluentBuilder, px, ClickEvent, Context, FontWeight, InteractiveElement, + IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, + WindowControlArea, }; use tracing::info; @@ -260,13 +261,16 @@ impl WorkspaceView { let muted: gpui::Hsla = theme.muted.into(); let hover_bg: gpui::Hsla = theme.active.into(); let destructive = super::types::DESTRUCTIVE_ITEM_COLOR; + let orange: gpui::Hsla = theme.orange.into(); // Check if this session has a group - let session_group = self - .workspace() - .session(session_id) - .and_then(|s| s.group.clone()); + let (session_group, session_shell) = { + let session = self.workspace().session(session_id)?; + (session.group.clone(), session.shell.clone()) + }; let has_group = session_group.is_some(); + let (shell_label, shell_warning) = + self.session_shell_display(session_id, session_shell.as_deref()); // Collect existing group names (deduplicated, sorted) let existing_groups: Vec = { @@ -316,6 +320,28 @@ impl WorkspaceView { .flex_col() .py_1(); + dropdown = dropdown + .child( + div() + .px_3() + .pt_2() + .pb_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(muted.opacity(0.6)) + .child("SHELL"), + ) + .child(div().text_sm().text_color(fg).child(shell_label)) + .when_some(shell_warning, |el, warning| { + el.child(div().text_xs().text_color(orange).child(warning)) + }), + ) + .child(div().h(px(1.0)).mx_2().my_1().bg(border_color)); + // Rename dropdown = dropdown.child(self.render_menu_item( "Rename", diff --git a/crates/codirigent-ui/src/workspace/split_render.rs b/crates/codirigent-ui/src/workspace/split_render.rs new file mode 100644 index 00000000..5e670100 --- /dev/null +++ b/crates/codirigent-ui/src/workspace/split_render.rs @@ -0,0 +1,374 @@ +//! Split-tree workspace rendering. +//! +//! This module owns the recursive rendering path for split-tree layouts, +//! including divider hit targets and empty split slots. + +use crate::icons; +use crate::theme::CodirigentTheme; +use crate::workspace::gpui::WorkspaceView; +use codirigent_core::{LayoutNode, SlotId, SplitDirection}; +use gpui::{ + div, prelude::FluentBuilder, px, relative, ClickEvent, Context, InteractiveElement, + IntoElement, MouseButton, MouseDownEvent, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, +}; +use tracing::info; + +impl WorkspaceView { + /// Render the split tree layout using recursive binary tree traversal. + pub(super) fn render_split_tree_layout( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + let theme = self.workspace().theme().clone(); + let grid_gap = theme.grid_gap; + let grid_bounds = self.workspace().grid_bounds(); + + let tree = match self.workspace().layout_state() { + crate::layout::WorkspaceLayoutState::SplitTree(s) => s.tree().clone(), + _ => return div().flex_1().into_any_element(), + }; + + let panel_bg: gpui::Hsla = theme.panel_background.into(); + let border_color: gpui::Hsla = theme.border.into(); + let muted: gpui::Hsla = theme.muted.into(); + + self.render_split_node( + &tree, + grid_bounds, + &theme, + grid_gap, + panel_bg, + border_color, + muted, + window, + cx, + ) + } + + /// Recursively render a layout node in the split tree. + #[allow(clippy::too_many_arguments)] + fn render_split_node( + &mut self, + node: &LayoutNode, + available: crate::layout::Bounds, + theme: &CodirigentTheme, + gap: f32, + panel_bg: gpui::Hsla, + border_color: gpui::Hsla, + muted: gpui::Hsla, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + match node { + LayoutNode::Leaf { slot } => { + let focused_session = self.workspace().focused_session_id(); + let session_data = self + .workspace() + .layout_state() + .as_split_tree() + .and_then(|state| state.session_at_slot(*slot)) + .and_then(|session_id| { + self.workspace().session(session_id).map(|session| { + ( + session_id, + focused_session == Some(session_id), + session.name.clone(), + session.status, + ) + }) + }); + + if let Some((session_id, is_focused, name, status)) = session_data { + let header_hints = if let Some(header) = self.get_terminal_header(session_id) { + header.render_hints() + } else { + crate::terminal_header::TerminalHeader::new(name, status) + .with_focused(is_focused) + .render_hints() + }; + + self.render_session_cell_with_terminal( + codirigent_core::PaneId::SplitSlot { slot: *slot }, + session_id, + &header_hints, + theme, + None, + window, + cx, + ) + .into_any_element() + } else { + self.render_split_empty_slot(*slot, panel_bg, border_color, muted, cx) + .into_any_element() + } + } + LayoutNode::Split { + direction, + ratio, + first, + second, + } => { + let (first_bounds, second_bounds, divider_bounds) = + split_child_bounds(*direction, *ratio, available, gap); + let first_slot = first.slots_in_order().first().copied().unwrap_or(SlotId(0)); + let second_slot = second + .slots_in_order() + .first() + .copied() + .unwrap_or(SlotId(0)); + let is_resizing = self.selection.split_resize.as_ref().is_some_and(|resize| { + resize.first_slot == first_slot && resize.second_slot == second_slot + }); + + let first_elem = self.render_split_node( + first, + first_bounds, + theme, + gap, + panel_bg, + border_color, + muted, + window, + cx, + ); + let second_elem = self.render_split_node( + second, + second_bounds, + theme, + gap, + panel_bg, + border_color, + muted, + window, + cx, + ); + + let first_flex = *ratio * 1000.0; + let second_flex = (1.0 - *ratio) * 1000.0; + let is_horizontal = *direction == SplitDirection::Horizontal; + + let make_child_div = |elem: gpui::AnyElement, flex: f32| -> gpui::Div { + let mut child = div().flex().size_full(); + child = if is_horizontal { + child.flex_col() + } else { + child.flex_row() + }; + child.style().flex_grow = Some(flex); + child.style().flex_shrink = Some(1.0); + child.style().flex_basis = Some(relative(0.).into()); + child.child(elem) + }; + + let divider = self.render_split_divider( + *direction, + available, + divider_bounds, + gap, + first_slot, + second_slot, + theme, + border_color, + is_resizing, + cx, + ); + + let mut container = div().flex_1().flex(); + container = if is_horizontal { + container.flex_row() + } else { + container.flex_col() + }; + container + .child(make_child_div(first_elem, first_flex)) + .child(divider) + .child(make_child_div(second_elem, second_flex)) + .into_any_element() + } + } + } + + #[allow(clippy::too_many_arguments)] + fn render_split_divider( + &mut self, + direction: SplitDirection, + resize_bounds: crate::layout::Bounds, + divider_bounds: crate::layout::Bounds, + gap: f32, + first_slot: SlotId, + second_slot: SlotId, + theme: &CodirigentTheme, + border_color: gpui::Hsla, + is_resizing: bool, + cx: &mut Context, + ) -> gpui::Stateful { + let primary: gpui::Hsla = theme.primary.into(); + let divider_bg = if is_resizing { + primary.opacity(0.35) + } else { + border_color.opacity(0.45) + }; + let divider_hover = if is_resizing { + primary.opacity(0.45) + } else { + primary.opacity(0.22) + }; + let divider_origin = match direction { + SplitDirection::Horizontal => divider_bounds.origin.x, + SplitDirection::Vertical => divider_bounds.origin.y, + }; + + div() + .id(SharedString::from(format!( + "split-divider-{}-{}", + first_slot.0, second_slot.0 + ))) + .flex_shrink_0() + .bg(divider_bg) + .when(direction == SplitDirection::Horizontal, |this| { + this.w(px(divider_bounds.size.width)) + .h_full() + .cursor_col_resize() + }) + .when(direction == SplitDirection::Vertical, |this| { + this.h(px(divider_bounds.size.height)) + .w_full() + .cursor_row_resize() + }) + .hover(|style| style.bg(divider_hover)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + let pos = + crate::layout::Point::new(event.position.x.into(), event.position.y.into()); + this.selection.drag = None; + this.selection.split_resize = Some(super::types::SplitResizeState { + first_slot, + second_slot, + direction, + bounds: resize_bounds, + gap, + grab_offset: match direction { + SplitDirection::Horizontal => (pos.x - divider_origin).max(0.0), + SplitDirection::Vertical => (pos.y - divider_origin).max(0.0), + }, + changed: false, + }); + this.selection.is_selecting = false; + this.selection.selecting_session_id = None; + cx.notify(); + }), + ) + } + + /// Render an empty slot in split tree mode. + fn render_split_empty_slot( + &mut self, + slot: SlotId, + panel_bg: gpui::Hsla, + border_color: gpui::Hsla, + muted: gpui::Hsla, + cx: &mut Context, + ) -> gpui::Stateful { + div() + .id(SharedString::from(format!("empty-slot-{}", slot.0))) + .size_full() + .bg(panel_bg) + .border_1() + .border_color(border_color) + .rounded_lg() + .border_dashed() + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .cursor_pointer() + .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { + info!(?slot, "Empty split slot clicked — creating session"); + this.create_session_in_slot(slot, cx); + })) + .child( + div() + .text_xl() + .text_color(muted) + .font_family(icons::LUCIDE_FONT_FAMILY) + .child(icons::circle_plus()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(super::types::EMPTY_CELL_MESSAGE), + ) + } +} + +fn split_child_bounds( + direction: SplitDirection, + ratio: f32, + available: crate::layout::Bounds, + gap: f32, +) -> ( + crate::layout::Bounds, + crate::layout::Bounds, + crate::layout::Bounds, +) { + match direction { + SplitDirection::Horizontal => { + let total_w = (available.size.width - gap).max(0.0); + let first_w = (total_w * ratio).max(0.0); + let second_w = (total_w - first_w).max(0.0); + let divider_x = available.origin.x + first_w; + ( + crate::layout::Bounds::new( + available.origin.x, + available.origin.y, + first_w, + available.size.height, + ), + crate::layout::Bounds::new( + divider_x + gap, + available.origin.y, + second_w, + available.size.height, + ), + crate::layout::Bounds::new( + divider_x, + available.origin.y, + gap, + available.size.height, + ), + ) + } + SplitDirection::Vertical => { + let total_h = (available.size.height - gap).max(0.0); + let first_h = (total_h * ratio).max(0.0); + let second_h = (total_h - first_h).max(0.0); + let divider_y = available.origin.y + first_h; + ( + crate::layout::Bounds::new( + available.origin.x, + available.origin.y, + available.size.width, + first_h, + ), + crate::layout::Bounds::new( + available.origin.x, + divider_y + gap, + available.size.width, + second_h, + ), + crate::layout::Bounds::new( + available.origin.x, + divider_y, + available.size.width, + gap, + ), + ) + } + } +} diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 37023c7b..252b5490 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -3,7 +3,10 @@ use super::core::*; use crate::layout::{Bounds, FocusDirection, LayoutProfile, Point}; use crate::theme::CodirigentTheme; -use codirigent_core::{LayoutNode, Session, SessionId, SessionStatus, SlotId, SplitDirection}; +use codirigent_core::{ + LayoutNode, PaneId, PaneStackState, PaneTabGroup, Session, SessionId, SessionStatus, SlotId, + SplitDirection, +}; use std::path::PathBuf; fn make_session(id: u64, name: &str) -> Session { @@ -69,9 +72,11 @@ fn test_workspace_add_session_full() { assert!(ws.add_session(make_session(i, &format!("Session {}", i)))); } - // 5th should fail - assert!(!ws.add_session(make_session(5, "Session 5"))); - assert_eq!(ws.sessions().len(), 4); + // 5th should be retained as a hidden session + assert!(ws.add_session(make_session(5, "Session 5"))); + assert_eq!(ws.sessions().len(), 5); + assert_eq!(ws.visible_sessions().len(), 4); + assert!(!ws.is_session_visible(SessionId(5))); } #[test] @@ -158,6 +163,17 @@ fn test_workspace_focus_session_number() { assert!(!ws.focus_session_number(10)); } +#[test] +fn test_workspace_focus_session_number_ignores_hidden_multi_pane_grid_sessions() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("Session {}", i)))); + } + + assert!(!ws.focus_session_number(5)); + assert_eq!(ws.focused_session_id(), Some(SessionId(1))); +} + #[test] fn test_workspace_focus_navigation() { let mut ws = Workspace::new(); @@ -347,11 +363,13 @@ fn test_workspace_cell_info() { #[test] fn test_cell_info_fields() { let info = CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: SessionId(1), index: 0, bounds: Bounds::from_size(100.0, 100.0), }; + assert_eq!(info.pane_id, PaneId::GridCell { index: 0 }); assert_eq!(info.session_id, SessionId(1)); assert_eq!(info.index, 0); assert_eq!(info.bounds.size.width, 100.0); @@ -455,6 +473,64 @@ fn test_workspace_single_layout_preserves_order_on_exit() { assert_eq!(ws.focused_session_id(), Some(SessionId(3))); } +#[test] +fn test_workspace_focus_hidden_grid_session_swaps_into_focused_pane() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + ws.set_bounds(Bounds::from_size(1000.0, 800.0)); + + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(2), SessionId(3), SessionId(4)] + ); + assert!(!ws.is_session_visible(SessionId(5))); + + assert!(ws.focus_session(SessionId(2))); + assert!(ws.focus_session(SessionId(5))); + + let cells = ws.cell_info(); + assert_eq!( + cells.iter().map(|cell| cell.session_id).collect::>(), + vec![SessionId(1), SessionId(5), SessionId(3), SessionId(4)] + ); + assert_eq!(ws.focused_session_id(), Some(SessionId(5))); + assert!(!ws.is_session_visible(SessionId(2))); + assert!(ws.is_session_visible(SessionId(5))); +} + +#[test] +fn test_workspace_focus_hidden_grid_session_uses_first_visible_pane_when_focus_is_hidden() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + ws.set_bounds(Bounds::from_size(1000.0, 800.0)); + + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + ws.set_layout(LayoutProfile::Single); + assert!(ws.focus_session(SessionId(5))); + assert_eq!(ws.focused_session_id(), Some(SessionId(5))); + + ws.set_layout(LayoutProfile::Grid2x2); + assert_eq!(ws.focused_session_id(), Some(SessionId(1))); + + assert!(ws.focus_session(SessionId(2))); + assert!(ws.focus_session(SessionId(5))); + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(5), SessionId(3), SessionId(4)] + ); +} + #[test] fn test_workspace_restores_hidden_sessions_after_returning_from_smaller_split_layout() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); @@ -568,6 +644,37 @@ fn test_workspace_split_pane_promotes_next_hidden_session() { ); } +#[test] +fn test_workspace_focus_hidden_split_session_replaces_focused_visible_session() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=4 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + ws.set_split_tree(LayoutNode::from_grid(1, 2)); + assert!(ws.is_split_tree_mode()); + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(2)] + ); + + assert!(ws.focus_session(SessionId(2))); + assert!(ws.focus_session(SessionId(4))); + + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(4)] + ); + assert_eq!(ws.focused_session_id(), Some(SessionId(4))); + assert!(!ws.is_session_visible(SessionId(2))); +} + #[test] fn test_workspace_remove_session_promotes_hidden_split_session() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); @@ -587,6 +694,43 @@ fn test_workspace_remove_session_promotes_hidden_split_session() { ); } +#[test] +fn test_workspace_resize_split_divider_updates_nested_layout_ratio() { + let mut ws = Workspace::new(); + let tree = LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.5, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + }; + ws.set_split_tree(tree); + + assert!(ws.resize_split_divider(SlotId(0), SlotId(2), 0.75)); + let split_tree = match ws.layout_state().as_split_tree() { + Some(split) => split, + None => panic!("expected split-tree layout"), + }; + assert_eq!( + split_tree.tree(), + &LayoutNode::Split { + direction: SplitDirection::Horizontal, + ratio: 0.75, + first: Box::new(LayoutNode::Split { + direction: SplitDirection::Vertical, + ratio: 0.5, + first: Box::new(LayoutNode::Leaf { slot: SlotId(0) }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(1) }), + }), + second: Box::new(LayoutNode::Leaf { slot: SlotId(2) }), + } + ); +} + #[test] fn test_set_split_tree_from_existing_split() { let mut ws = Workspace::new(); @@ -683,6 +827,29 @@ fn test_close_pane_removes_session_from_workspace() { assert!(ws.session(SessionId(2)).is_some()); } +#[test] +fn test_close_tabbed_split_pane_requires_closing_all_pane_sessions() { + let mut ws = Workspace::new(); + let tree = LayoutNode::from_grid(1, 2); + ws.set_split_tree(tree); + + ws.add_session(make_session(1, "S1")); + ws.add_session(make_session(2, "S2")); + assert!(ws.group_session_into_pane(SessionId(1), PaneId::SplitSlot { slot: SlotId(1) })); + assert!(ws.focus_session(SessionId(1))); + + let pane_session_ids = ws.focused_pane_session_ids(); + assert_eq!(pane_session_ids, vec![SessionId(2), SessionId(1)]); + + assert!(ws.close_pane()); + for id in pane_session_ids { + ws.remove_session(id); + } + + assert!(ws.sessions().is_empty()); + assert!(ws.pane_tab_groups().is_empty()); +} + #[test] fn test_string_truncation_no_allocation_when_short() { use std::borrow::Cow; @@ -775,7 +942,10 @@ fn test_workspace_swap_sessions_split_tree_with_empty_slot() { // Swap S1 from slot 0 to empty slot 2 assert!(ws.swap_sessions(0, 2)); - let split = ws.layout_state().as_split_tree().unwrap(); + let split = match ws.layout_state().as_split_tree() { + Some(split) => split, + None => panic!("expected split-tree layout"), + }; assert_eq!(split.assignments()[0].1, None); assert_eq!(split.assignments()[2].1, Some(SessionId(1))); } @@ -828,16 +998,235 @@ fn test_workspace_swap_sessions_split_tree_after_split_respects_visual_order() { ); } +#[test] +fn test_workspace_group_session_into_grid_pane_creates_tabs_without_reflow() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=4 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + + let cells = ws.cell_info(); + assert_eq!(cells.len(), 3); + assert!(cells.iter().all(|cell| cell.index != 0)); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2), SessionId(1)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(1)) + ); + assert!(ws.is_session_visible(SessionId(2))); +} + +#[test] +fn test_workspace_activate_pane_tab_switches_active_session() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=3 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert!(ws.activate_pane_tab(PaneId::GridCell { index: 1 }, SessionId(2))); + + assert_eq!(ws.focused_session_id(), Some(SessionId(2))); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(2)) + ); +} + +#[test] +fn test_workspace_add_session_to_existing_pane_creates_active_tab() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "S1"))); + assert!(ws.add_session(make_session(2, "S2"))); + + assert!(ws.add_session_to_pane(make_session(3, "S3"), PaneId::GridCell { index: 0 })); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(3)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(3)) + ); + assert_eq!(ws.focused_session_id(), Some(SessionId(3))); +} + +#[test] +fn test_workspace_remove_active_tab_promotes_next_tab() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=3 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(1)) + ); + + let removed = ws.remove_session(SessionId(1)); + assert!(removed.is_some()); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(2)) + ); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2)] + ); +} + +#[test] +fn test_workspace_restore_pane_tab_groups_rehydrates_active_tabs() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(11, "S11"))); + assert!(ws.add_session(make_session(12, "S12"))); + assert!(ws.add_session(make_session(13, "S13"))); + + let saved_groups = vec![PaneTabGroup { + pane: PaneId::GridCell { index: 1 }, + session_ids: vec![SessionId(2), SessionId(1)], + active_session_id: SessionId(1), + }]; + let restored_ids = std::collections::HashMap::from([ + (SessionId(1), SessionId(11)), + (SessionId(2), SessionId(12)), + ]); + + ws.restore_pane_tab_groups(&saved_groups, &restored_ids); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(12), SessionId(11)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(11)) + ); +} + +#[test] +fn test_layout_changes_preserve_hidden_pane_stacks_and_active_tabs() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "S1"))); + assert!(ws.add_session(make_session(2, "S2"))); + assert!(ws.add_session(make_session(3, "S3"))); + assert!(ws.add_session(make_session(4, "S4"))); + assert!(ws.group_session_into_pane(SessionId(2), PaneId::GridCell { index: 0 })); + assert!(ws.group_session_into_pane(SessionId(4), PaneId::GridCell { index: 2 })); + + ws.set_layout(LayoutProfile::Single); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(2)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(2)) + ); + assert_eq!( + ws.pane_stacks(), + vec![ + PaneStackState { + session_ids: vec![SessionId(1), SessionId(2)], + active_session_id: SessionId(2), + }, + PaneStackState { + session_ids: vec![SessionId(3), SessionId(4)], + active_session_id: SessionId(4), + }, + ] + ); + + ws.set_layout(LayoutProfile::Grid2x2); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(2)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(2)) + ); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(3), SessionId(4)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 1 }), + Some(SessionId(4)) + ); +} + +#[test] +fn test_workspace_restore_pane_stacks_preserves_hidden_stack_order() { + let mut ws = Workspace::with_profile(LayoutProfile::Single); + assert!(ws.add_session(make_session(11, "S11"))); + assert!(ws.add_session(make_session(12, "S12"))); + assert!(ws.add_session(make_session(13, "S13"))); + assert!(ws.add_session(make_session(14, "S14"))); + + let saved_stacks = vec![ + PaneStackState { + session_ids: vec![SessionId(2), SessionId(1)], + active_session_id: SessionId(1), + }, + PaneStackState { + session_ids: vec![SessionId(4), SessionId(3)], + active_session_id: SessionId(4), + }, + ]; + let restored_ids = std::collections::HashMap::from([ + (SessionId(1), SessionId(11)), + (SessionId(2), SessionId(12)), + (SessionId(3), SessionId(13)), + (SessionId(4), SessionId(14)), + ]); + + ws.restore_pane_stacks(&saved_stacks, &restored_ids); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(12), SessionId(11)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + Some(SessionId(11)) + ); + assert_eq!( + ws.pane_stacks(), + vec![ + PaneStackState { + session_ids: vec![SessionId(12), SessionId(11)], + active_session_id: SessionId(11), + }, + PaneStackState { + session_ids: vec![SessionId(14), SessionId(13)], + active_session_id: SessionId(14), + }, + ] + ); +} + #[cfg(feature = "gpui-full")] #[test] fn test_drag_state_updates_target_after_leaving_source_header() { let cells = vec![ CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: SessionId(1), index: 0, bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), }, CellInfo { + pane_id: PaneId::GridCell { index: 1 }, session_id: SessionId(2), index: 1, bounds: Bounds::new(120.0, 0.0, 100.0, 100.0), @@ -849,21 +1238,28 @@ fn test_drag_state_updates_target_after_leaving_source_header() { start_position: Point::new(10.0, 10.0), current_position: Point::new(10.0, 10.0), active: false, - target_index: None, + target: None, }; drag.update_pointer(Point::new(20.0, 20.0), &cells); assert!(drag.active); - assert_eq!(drag.target_index, None); + assert_eq!(drag.target, None); drag.update_pointer(Point::new(140.0, 20.0), &cells); - assert_eq!(drag.target_index, Some(1)); + assert_eq!( + drag.target, + Some(super::types::DragTarget { + index: 1, + kind: super::types::DragTargetKind::PaneHeader, + }) + ); } #[cfg(feature = "gpui-full")] #[test] fn test_drag_state_does_not_target_source_or_activate_too_early() { let cells = vec![CellInfo { + pane_id: PaneId::GridCell { index: 0 }, session_id: SessionId(1), index: 0, bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), @@ -874,14 +1270,17 @@ fn test_drag_state_does_not_target_source_or_activate_too_early() { start_position: Point::new(10.0, 10.0), current_position: Point::new(10.0, 10.0), active: false, - target_index: Some(0), + target: Some(super::types::DragTarget { + index: 0, + kind: super::types::DragTargetKind::PaneHeader, + }), }; drag.update_pointer(Point::new(12.0, 12.0), &cells); assert!(!drag.active); - assert_eq!(drag.target_index, None); + assert_eq!(drag.target, None); drag.update_pointer(Point::new(20.0, 20.0), &cells); assert!(drag.active); - assert_eq!(drag.target_index, None); + assert_eq!(drag.target, None); } diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index e1c37110..7711b177 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -4,7 +4,7 @@ //! implementation, including modal states and UI component data. use super::CellInfo; -use codirigent_core::{SessionId, SessionStatus, SlotId, TaskId}; +use codirigent_core::{PaneId, SessionId, SessionStatus, SlotId, TaskId}; use codirigent_session::codex_session_reader::CodexSessionReader; use codirigent_session::gemini_session_reader::GeminiSessionReader; use gpui::Hsla; @@ -37,6 +37,9 @@ pub(super) const DRAWER_HEADER_HEIGHT: f32 = 40.0; /// and for reverse-parsing the session number (`strip_prefix(SESSION_NAME_PREFIX)`). pub(super) const SESSION_NAME_PREFIX: &str = "Session "; +/// Label shown when a session uses the default shell resolution path. +pub(super) const SESSION_SHELL_AUTO_LABEL: &str = "Auto"; + /// Height of session and group rows in the Sessions drawer panel. pub(super) const SESSION_ROW_HEIGHT: f32 = 28.0; @@ -226,6 +229,33 @@ pub(super) struct TaskCreationModal { pub(super) editing_task_id: Option, } +/// Session creation modal state. +/// +/// This modal is used for every session creation entry point so shell selection +/// stays consistent whether the user clicks an empty pane, a pane-local `+`, +/// or the generic create action. +#[derive(Debug, Clone)] +pub(super) struct SessionCreationModal { + /// Pane that should receive the created session, if any. + pub(super) target_pane: Option, + /// Stored shell values; empty string means Auto. + pub(super) shell_options: Vec, + /// Currently selected option index. + pub(super) selected_shell_index: usize, + /// Whether a background create request is currently in flight. + pub(super) pending: bool, + /// Optional validation or creation error. + pub(super) error: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct RestoreShellFallback { + /// Shell originally requested by the saved session. + pub(super) requested_shell: String, + /// Shell actually launched for the restored session. `None` means Auto. + pub(super) effective_shell: Option, +} + /// Context menu state for file tree right-click. /// /// Captures the position and target of a file tree context menu invocation. @@ -244,6 +274,8 @@ pub(super) struct FileTreeContextMenu { pub(super) struct ModalState { /// Session action modal state (rename/group). pub session_action: Option, + /// Session creation modal state. + pub session_creation: Option, /// Task creation modal state. pub task_creation: Option, /// Pending layout profile deletion: (tab_index, profile_name) awaiting confirmation. @@ -256,6 +288,7 @@ impl ModalState { pub fn new() -> Self { Self { session_action: None, + session_creation: None, task_creation: None, pending_profile_deletion: None, cursor_blink_on: true, @@ -281,12 +314,29 @@ pub(super) struct SelectionState { pub last_click_position: Option<(codirigent_core::GridPosition, Instant)>, /// Active drag-and-drop state for session reordering (None when not dragging). pub drag: Option, + /// Active split-divider resize gesture (None when not resizing). + pub split_resize: Option, } /// State for drag-and-drop session reordering. /// /// Tracks an in-progress drag operation where the user is moving a session /// from one pane to another by dragging its header bar. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum DragTargetKind { + PaneBody, + PaneHeader, +} + +/// Current drop target under the pointer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct DragTarget { + /// Grid or split logical cell index. + pub index: usize, + /// Whether the pointer is over the header or body region. + pub kind: DragTargetKind, +} + #[derive(Debug, Clone, Copy)] pub(super) struct DragState { /// Session being dragged. @@ -299,8 +349,26 @@ pub(super) struct DragState { pub current_position: crate::layout::Point, /// Whether the drag threshold (5px) has been exceeded. pub active: bool, - /// Index of the cell currently under the cursor (drop target), if any. - pub target_index: Option, + /// Cell currently under the cursor (drop target), if any. + pub target: Option, +} + +#[derive(Debug, Clone, Copy)] +pub(super) struct SplitResizeState { + /// Representative slot from the first subtree of the resized split. + pub first_slot: SlotId, + /// Representative slot from the second subtree of the resized split. + pub second_slot: SlotId, + /// Direction of the split being resized. + pub direction: codirigent_core::SplitDirection, + /// Global bounds of the split container. + pub bounds: crate::layout::Bounds, + /// Gap thickness used for the divider. + pub gap: f32, + /// Pointer offset within the divider handle at drag start. + pub grab_offset: f32, + /// Whether the drag produced at least one ratio change. + pub changed: bool, } const DRAG_ACTIVATION_DISTANCE_SQUARED: f32 = 25.0; @@ -318,17 +386,29 @@ impl DragState { let dx = position.x - self.start_position.x; let dy = position.y - self.start_position.y; if (dx * dx + dy * dy) <= DRAG_ACTIVATION_DISTANCE_SQUARED { - self.target_index = None; + self.target = None; return; } self.active = true; } - self.target_index = cells + self.target = cells .iter() .find(|cell| cell.bounds.contains(position)) - .map(|cell| cell.index) - .filter(|&target| target != self.source_index); + .and_then(|cell| { + (cell.index != self.source_index).then(|| { + let header_bottom = cell.bounds.origin.y + HEADER_HEIGHT; + let kind = if position.y <= header_bottom { + DragTargetKind::PaneHeader + } else { + DragTargetKind::PaneBody + }; + DragTarget { + index: cell.index, + kind, + } + }) + }); } } @@ -342,6 +422,7 @@ impl SelectionState { file_tree_context_menu: None, last_click_position: None, drag: None, + split_resize: None, } } } @@ -501,6 +582,8 @@ pub(super) struct CacheState { pub detected_editors: Option>, /// Cached available shells detected from the system (populated in background on init). pub detected_shells: Option>, + /// Sessions restored with a fallback shell because their requested shell was unavailable. + pub restore_shell_fallbacks: HashMap, /// Last PTY-resized dimensions per session, used to skip redundant resize calls. pub pty_sizes: HashMap, /// Sessions that have received at least one manual task assignment. @@ -534,6 +617,7 @@ impl CacheState { monospace_fonts: None, detected_editors: None, detected_shells: None, + restore_shell_fallbacks: HashMap::new(), pty_sizes: HashMap::new(), manually_assigned_sessions: HashSet::new(), compaction_start_times: HashMap::new(), diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 7529325c..46b56a2d 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -159,6 +159,9 @@ All data stored in `.codirigent/` directory: - Read [Data Flow](data-flow.md) for detailed flow diagrams - Read [Workspace Architecture](workspace/README.md) for the `codirigent-ui` - workspace layout, render roots, and polling/status boundaries + workspace layout, render roots, pointer interactions, and polling/status + boundaries +- Read [Crate Dependencies](crate-dependencies.md) for dependency graph +- Read [Event Bus](event-bus.md) for event system details - Read [../hook-and-status-system.md](../hook-and-status-system.md) for the hook-signal and session-status pipeline diff --git a/docs/architecture/workspace/README.md b/docs/architecture/workspace/README.md index 7014ff2f..a204b806 100644 --- a/docs/architecture/workspace/README.md +++ b/docs/architecture/workspace/README.md @@ -1,7 +1,7 @@ # Workspace Architecture -This directory documents the `codirigent-ui::workspace` module after the -module split. The goal is to let a future developer or coding agent answer +This directory documents the current `codirigent-ui::workspace` module +structure. The goal is to let a future developer or coding agent answer "where does this behavior live?" without opening every file in `crates/codirigent-ui/src/workspace/`. @@ -12,10 +12,11 @@ module split. The goal is to let a future developer or coding agent answer - Which files are roots, helpers, renderers, or state containers. - [GPUI And Rendering](gpui.md) - - `WorkspaceView`, render orchestration, UI event translation, layout sync. + - `WorkspaceView`, render orchestration, pointer interactions, UI event + translation, and layout sync. - [Output Polling And Status](output-polling.md) - - PTY output flow, status reconciliation, JSONL polling, hook signals. + - PTY output flow, status reconciliation, JSONL polling, and hook signals. ## Workspace In One Screen @@ -41,27 +42,56 @@ Everything else in `workspace/` either: - renders a specific UI region - stores grouped sub-state used by the roots above +Recent structural extractions to know first: + +- `grid_render.rs` + - Grid-layout composition, split/grid dispatch, and shared session-cell + rendering. + +- `split_render.rs` + - Recursive split-tree rendering, divider setup, and empty split slots. + +- `pane_header_render.rs` + - Pane-header tabs, badges, and pane-local session creation controls. + +- `impl_pointer_interactions.rs` + - Workspace-global drag/resize reducers used by the GPUI root. + ## Quick Lookup If you need to change: -- layout switching, focus movement, terminal resize: +- layout switching, focus movement, or terminal resize: - [gpui.md](gpui.md) - `gpui/layout_sync.rs` -- task board counts, header badges, empty cell sync: +- split-tree rendering or divider behavior: + - [gpui.md](gpui.md) + - `split_render.rs` + - `impl_pointer_interactions.rs` + +- pane tabs, header badges, or pane `+` behavior: + - [gpui.md](gpui.md) + - `pane_header_render.rs` + +- grid cells and split/grid render dispatch: + - [gpui.md](gpui.md) + - `grid_render.rs` + +- task board counts, header badges, or empty-cell sync: - [gpui.md](gpui.md) - `gpui/derived_state.rs` -- top bar, icon rail, empty-cell event translation: +- top bar, icon rail, or empty-cell event translation: - [gpui.md](gpui.md) - `gpui/ui_events.rs` -- PTY output draining, terminal runtime application, output scheduling: +- PTY output draining, terminal runtime application, or output scheduling: - [output-polling.md](output-polling.md) - `impl_output_polling/output_runtime.rs` -- session status decisions, stale cache clearing, auto-assign/compaction follow-up: +- session status decisions, stale cache clearing, or auto-assign/compaction + follow-up: - [output-polling.md](output-polling.md) - `impl_output_polling/status_reconcile.rs` diff --git a/docs/architecture/workspace/gpui.md b/docs/architecture/workspace/gpui.md index a6719dcd..82efc3fc 100644 --- a/docs/architecture/workspace/gpui.md +++ b/docs/architecture/workspace/gpui.md @@ -12,6 +12,7 @@ the split. It owns: - grouped UI state fields - GPUI trait impls (`Render`, focus, IME/input handling) - high-level render orchestration +- root event wiring for workspace-global pointer gestures - keyboard and IME behavior that still benefits from staying close to the root The split was about moving lower-coupling helper clusters out of the root, not @@ -85,6 +86,7 @@ Converts canonical session/task state into cached UI state: - task board counts and snapshots - terminal header state +- shell badge and restore-warning synchronization - empty-grid-cell state Key rule: @@ -117,6 +119,57 @@ Owns the follow-up work after layout or selection changes: This is the main place where UI layout changes meet terminal runtime behavior. +## Render-Facing Helper Modules + +The GPUI root now delegates most visual composition into smaller render +modules: + +### `grid_render.rs` + +Owns: + +- grid-layout composition +- split-vs-grid dispatch +- shared session-cell rendering used by both grid and split layouts + +### `split_render.rs` + +Owns: + +- recursive split-tree rendering +- divider visuals and drag-start wiring +- empty split-slot rendering + +### `pane_header_render.rs` + +Owns: + +- pane-local tab strips +- header badges and title metadata +- active-tab drag affordances +- pane-local `+` session creation affordance + +These modules keep `gpui.rs` as the UI root without forcing it to inline every +render detail. + +## Pointer Interaction Flow + +Workspace-global pointer gestures are now split between: + +- `gpui.rs` + - root GPUI event hooks (`on_mouse_move`, `on_mouse_up`) + - wiring from GPUI events into workspace reducers + +- `impl_pointer_interactions.rs` + - split-resize move/update/finalize + - session-drag move/finalize + - cancellation when a drag ends outside the normal path + +Design rule: + +- `gpui.rs` should stay as the place where root GPUI hooks are discoverable +- gesture-specific mutation logic should live in focused helper modules + ## Render Path The high-level render flow is: @@ -128,6 +181,8 @@ The high-level render flow is: 5. delegate UI composition into render-focused modules such as: - `render.rs` - `grid_render.rs` + - `split_render.rs` + - `pane_header_render.rs` - `drawer_render.rs` - `task_board_render.rs` @@ -138,10 +193,11 @@ Important design choice: ## Mutation Path Rules -When a workspace mutation changes visible state, the usual follow-up pattern is: +When a workspace mutation changes visible state, the usual follow-up pattern +is: 1. mutate canonical workspace state -2. call `mark_layout_cache_dirty()` if structure/bounds changed +2. call `mark_layout_cache_dirty()` if structure or bounds changed 3. call `sync_layout_derived_state()` or `sync_task_derived_state()` 4. run any file-tree or session-selection follow-up 5. `cx.notify()` if the UI should repaint @@ -157,7 +213,7 @@ Examples that follow this pattern: If you need to change: -- terminal header fields: +- terminal header fields or shell labels: - `gpui/derived_state.rs` - task board counters or task snapshot contents: @@ -179,6 +235,14 @@ If you need to change: - resize throttling or collapsed-resize guards: - `gpui/layout_sync.rs` + - `impl_pointer_interactions.rs` + +- split divider drag behavior: + - `split_render.rs` + - `impl_pointer_interactions.rs` + +- pane tabs, badges, or pane `+` behavior: + - `pane_header_render.rs` - key handling or IME behavior: - `gpui.rs` diff --git a/docs/architecture/workspace/module-map.md b/docs/architecture/workspace/module-map.md index f57756b1..28ab3404 100644 --- a/docs/architecture/workspace/module-map.md +++ b/docs/architecture/workspace/module-map.md @@ -21,6 +21,7 @@ Canonical workspace model: - session placement and removal - grid/split-tree layout state +- pane tab groups and pane stacks - focus movement - cell bounds and visible session calculation @@ -34,6 +35,7 @@ Primary UI root: - owns constructor wiring and grouped UI state - keeps GPUI trait impls easy to find - coordinates render-time orchestration +- wires root pointer events into gesture reducers Helper clusters that extend the root now live under `workspace/gpui/`. @@ -67,6 +69,7 @@ Mutation-driven UI reducers: - task board snapshot/count refresh - terminal header synchronization +- shell badge and warning synchronization - empty-cell synchronization - explicit derived-state refresh entry points @@ -193,7 +196,19 @@ These mostly build UI elements rather than owning long-lived behavior: - top-level composition for the workspace body - `grid_render.rs` - - grid and split-tree cells + - grid-layout composition + - split-vs-grid render dispatch + - shared session-cell rendering + +- `split_render.rs` + - split-tree recursion + - divider rendering and drag hit areas + - empty split-slot rendering + +- `pane_header_render.rs` + - pane-header tabs + - header badges and title rows + - pane-local `+` session creation affordance - `drawer_render.rs` - drawer panels and left-side content @@ -213,6 +228,13 @@ These mostly build UI elements rather than owning long-lived behavior: - `terminal_render.rs` - terminal-specific render helpers +### Pointer interaction helpers + +- `impl_pointer_interactions.rs` + - split-resize drag reducers + - session-drag move/finalize reducers + - workspace-global gesture completion and cancellation + ### State containers These group related state to keep `WorkspaceView` readable: @@ -247,6 +269,16 @@ For common tasks: - "Why did a layout or selection change affect terminal sizing?" - `gpui/layout_sync.rs` - `grid_render.rs` + - `split_render.rs` + +- "Why did a header drag or divider drag behave strangely?" + - `impl_pointer_interactions.rs` + - `pane_header_render.rs` + - `split_render.rs` + +- "Where do pane tabs or pane-header badges come from?" + - `pane_header_render.rs` + - `gpui/derived_state.rs` - "Why is a session badge wrong?" - `impl_output_polling/status_reconcile.rs` diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 31536087..ea22dd9f 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -667,6 +667,7 @@ fn test_session_idle_to_working() { name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -701,6 +702,7 @@ fn test_session_working_to_needs_attention() { name: "Test Session".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -735,6 +737,7 @@ fn test_session_needs_attention_to_idle() { name: "Test Session".to_string(), status: SessionStatus::NeedsAttention, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -769,6 +772,7 @@ fn test_session_to_error_state() { name: "Test Session".to_string(), status: SessionStatus::Working, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(), @@ -800,6 +804,7 @@ fn test_session_state_invariants() { name: "Test Session".to_string(), status: SessionStatus::Idle, working_directory: PathBuf::from("/tmp"), + shell: None, current_task: None, context_usage: None, created_at: chrono::Utc::now(),