diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 05:10:13 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-02 23:24:46 +0300 |
| commit | b695adee60e50764f4713c8e586ca02fb844f99a (patch) | |
| tree | 6fd769682289fc8053e3cf966ad2ca2d5d4898dd /src/app | |
| parent | 28fb23cdaf4fce03ed6dbfcb4bb13be324b181ac (diff) | |
feat: add tabs within workspaces
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 411 | ||||
| -rw-r--r-- | src/app/input.rs | 258 | ||||
| -rw-r--r-- | src/app/mod.rs | 76 | ||||
| -rw-r--r-- | src/app/state.rs | 21 |
4 files changed, 504 insertions, 262 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 04ff19ac..279cbabf 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -3,10 +3,9 @@ use tracing::{info, warn}; -use crate::detect::{Agent, AgentState}; +use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; -use crate::pane::EffectiveStateChange; use super::state::{AppState, Mode, ToastKind, ToastNotification}; @@ -44,32 +43,22 @@ fn notification_toast_for_state_change( } } -fn agent_label(agent: Agent) -> &'static str { +fn agent_label(agent: crate::detect::Agent) -> &'static str { match agent { - Agent::Pi => "pi", - Agent::Claude => "claude", - Agent::Codex => "codex", - Agent::Gemini => "gemini", - Agent::Cursor => "cursor", - Agent::Cline => "cline", - Agent::OpenCode => "opencode", - Agent::GithubCopilot => "copilot", - Agent::Kimi => "kimi", - Agent::Droid => "droid", - Agent::Amp => "amp", + crate::detect::Agent::Pi => "pi", + crate::detect::Agent::Claude => "claude", + crate::detect::Agent::Codex => "codex", + crate::detect::Agent::Gemini => "gemini", + crate::detect::Agent::Cursor => "cursor", + crate::detect::Agent::Cline => "cline", + crate::detect::Agent::OpenCode => "opencode", + crate::detect::Agent::GithubCopilot => "copilot", + crate::detect::Agent::Kimi => "kimi", + crate::detect::Agent::Droid => "droid", + crate::detect::Agent::Amp => "amp", } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PaneStateUpdate { - pub pane_id: PaneId, - pub ws_idx: usize, - pub previous_agent: Option<Agent>, - pub previous_state: AgentState, - pub agent: Option<Agent>, - pub state: AgentState, -} - // --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- @@ -79,8 +68,56 @@ impl AppState { if idx < self.workspaces.len() { self.active = Some(idx); self.selected = idx; - for pane in self.workspaces[idx].panes.values_mut() { - pane.seen = true; + if let Some(ws) = self.workspaces.get_mut(idx) { + ws.switch_tab(ws.active_tab); + } + } + } + + pub fn switch_tab(&mut self, idx: usize) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + ws.switch_tab(idx); + } + } + + pub fn next_workspace(&mut self) { + if !self.workspaces.is_empty() { + let current = self.active.unwrap_or(self.selected); + let next = (current + 1) % self.workspaces.len(); + self.switch_workspace(next); + } + } + + pub fn previous_workspace(&mut self) { + if !self.workspaces.is_empty() { + let current = self.active.unwrap_or(self.selected); + let prev = if current == 0 { + self.workspaces.len() - 1 + } else { + current - 1 + }; + self.switch_workspace(prev); + } + } + + pub fn next_tab(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if !ws.tabs.is_empty() { + let next = (ws.active_tab + 1) % ws.tabs.len(); + ws.switch_tab(next); + } + } + } + + pub fn previous_tab(&mut self) { + if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if !ws.tabs.is_empty() { + let prev = if ws.active_tab == 0 { + ws.tabs.len() - 1 + } else { + ws.active_tab - 1 + }; + ws.switch_tab(prev); } } } @@ -113,8 +150,12 @@ impl AppState { let panes = &self.view.pane_infos; if let Some(focused) = panes.iter().find(|p| p.is_focused) { if let Some(target) = find_in_direction(focused, direction, panes) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.layout.focus_pane(target); + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { + tab.layout.focus_pane(target); } } } @@ -127,33 +168,63 @@ impl AppState { .pane_infos .iter() .fold(first.rect, |acc, p| acc.union(p.rect)); - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.layout.resize_focused(direction, 0.05, area); + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { + tab.layout.resize_focused(direction, 0.05, area); } } } pub fn cycle_pane(&mut self, reverse: bool) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { if reverse { - ws.layout.focus_prev(); + tab.layout.focus_prev(); } else { - ws.layout.focus_next(); + tab.layout.focus_next(); } } } pub fn toggle_fullscreen(&mut self) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if ws.layout.pane_count() > 1 { - ws.zoomed = !ws.zoomed; + if let Some(tab) = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .and_then(|ws| ws.active_tab_mut()) + { + if tab.layout.pane_count() > 1 { + tab.zoomed = !tab.zoomed; } } } pub fn close_pane(&mut self) { + let should_close_workspace = self + .active + .and_then(|i| self.workspaces.get_mut(i)) + .is_some_and(|ws| ws.close_focused()); + if should_close_workspace { + self.close_selected_workspace(); + } + } + + pub fn close_tab(&mut self) { + let should_close_workspace = self + .active + .and_then(|i| self.workspaces.get(i)) + .is_some_and(|ws| ws.tabs.len() <= 1); + if should_close_workspace { + self.close_selected_workspace(); + return; + } if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.close_focused(); + ws.close_active_tab(); } } } @@ -184,7 +255,7 @@ impl AppState { None => return, }; - let rt = match ws.runtimes.get(&sel.pane_id) { + let rt = match ws.runtime(sel.pane_id) { Some(r) => r, None => return, }; @@ -204,12 +275,9 @@ impl AppState { // --------------------------------------------------------------------------- impl AppState { - pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> { + pub fn handle_app_event(&mut self, event: AppEvent) { match event { - AppEvent::PaneDied { pane_id } => { - self.handle_pane_died(pane_id); - Vec::new() - } + AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id), AppEvent::UpdateReady { version } => { self.update_available = Some(version.clone()); self.update_dismissed = true; @@ -218,115 +286,65 @@ impl AppState { title: format!("updated to v{version}"), context: "restart to use it".to_string(), }); - Vec::new() } AppEvent::StateChanged { pane_id, agent, state, - } => self - .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state)) - .into_iter() - .collect(), - AppEvent::HookStateReported { - pane_id, - source, - agent, - state, - message, - } => self - .update_pane_state(pane_id, |pane| { - pane.set_hook_authority(source, agent, state, message) - }) - .into_iter() - .collect(), - AppEvent::HookAuthorityCleared { pane_id, source } => self - .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref())) - .into_iter() - .collect(), - AppEvent::HookAgentReleased { - pane_id, - source, - agent, - } => self - .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent)) - .into_iter() - .collect(), - } - } - - fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate> - where - F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>, - { - let ws_idx = self - .workspaces - .iter() - .position(|ws| ws.panes.contains_key(&pane_id))?; - let workspace_name = self.workspaces[ws_idx].display_name(); - let change = { - let pane = self.workspaces[ws_idx].panes.get_mut(&pane_id)?; - update(pane)? - }; - self.apply_pane_state_change(ws_idx, pane_id, &workspace_name, change); - Some(PaneStateUpdate { - pane_id, - ws_idx, - previous_agent: change.previous_agent, - previous_state: change.previous_state, - agent: change.agent, - state: change.state, - }) - } - - fn apply_pane_state_change( - &mut self, - ws_idx: usize, - pane_id: PaneId, - workspace_name: &str, - change: EffectiveStateChange, - ) { - let is_active_ws = self.active == Some(ws_idx); - let Some(pane) = self.workspaces[ws_idx].panes.get_mut(&pane_id) else { - return; - }; - - if change.state == AgentState::Idle - && change.previous_state != AgentState::Idle - && !is_active_ws - { - pane.seen = false; - } - - if self.sound.allows(change.agent) { - if let Some(sound) = notification_sound_for_state_change( - is_active_ws, - change.previous_state, - change.state, - ) { - crate::sound::play(sound); - } - } - - if self.toast_config.enabled { - if let (Some(agent), Some(kind)) = ( - change.agent, - notification_toast_for_state_change( - is_active_ws, - change.previous_state, - change.state, - ), - ) { - let event_text = match kind { - ToastKind::NeedsAttention => "needs attention", - ToastKind::Finished => "finished", - ToastKind::UpdateInstalled => "updated", - }; - self.toast = Some(ToastNotification { - kind, - title: format!("{} {}", agent_label(agent), event_text), - context: format!("{} · {}", workspace_name, ws_idx + 1), - }); + } => { + for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() { + let workspace_name = ws.display_name(); + let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else { + continue; + }; + if let Some(pane) = ws.tabs[tab_idx].panes.get_mut(&pane_id) { + let is_active_ws = self.active == Some(ws_idx) && ws.active_tab == tab_idx; + let prev_state = pane.state; + + // Mark unseen when transitioning to Idle in background + if state == AgentState::Idle + && prev_state != AgentState::Idle + && !is_active_ws + { + pane.seen = false; + } + + // Blocked prompts should always make noise; done sounds stay background-only. + if self.sound.allows(agent) { + if let Some(sound) = + notification_sound_for_state_change(is_active_ws, prev_state, state) + { + crate::sound::play(sound); + } + } + + if self.toast_config.enabled { + if let (Some(agent), Some(kind)) = ( + agent, + notification_toast_for_state_change( + is_active_ws, + prev_state, + state, + ), + ) { + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + }; + self.toast = Some(ToastNotification { + kind, + title: format!("{} {}", agent_label(agent), event_text), + context: format!("{} · {}", workspace_name, ws_idx + 1), + }); + } + } + + pane.detected_agent = agent; + pane.state = state; + break; + } + } } } } @@ -335,16 +353,19 @@ impl AppState { let ws_idx = self .workspaces .iter() - .position(|ws| ws.panes.contains_key(&pane_id)); + .position(|ws| ws.find_tab_index_for_pane(pane_id).is_some()); let Some(ws_idx) = ws_idx else { warn!(pane = pane_id.raw(), "PaneDied for unknown pane"); return; }; - let ws = &mut self.workspaces[ws_idx]; + let should_close_workspace = { + let ws = &mut self.workspaces[ws_idx]; + ws.remove_pane(pane_id) + }; - if ws.layout.pane_count() <= 1 { + if should_close_workspace { self.workspaces.remove(ws_idx); if self.workspaces.is_empty() { self.active = None; @@ -362,8 +383,6 @@ impl AppState { self.selected = self.workspaces.len() - 1; } } - } else { - ws.remove_pane(pane_id); } } } @@ -541,94 +560,6 @@ mod tests { } #[test] - fn hook_authority_ignores_fallback_state_changes() { - let mut state = app_with_workspaces(&["test"]); - let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - }); - state.handle_app_event(AppEvent::HookStateReported { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - state: AgentState::Working, - message: None, - }); - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - }); - - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert_eq!(pane.fallback_state, AgentState::Idle); - assert_eq!(pane.state, AgentState::Working); - assert!(pane.hook_authority.is_some()); - } - - #[test] - fn hook_authority_clears_when_agent_disappears() { - let mut state = app_with_workspaces(&["test"]); - let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - }); - state.handle_app_event(AppEvent::HookStateReported { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - state: AgentState::Working, - message: None, - }); - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: None, - state: AgentState::Unknown, - }); - - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.state, AgentState::Unknown); - } - - #[test] - fn hook_agent_release_clears_identity_immediately() { - let mut state = app_with_workspaces(&["test"]); - let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); - - state.handle_app_event(AppEvent::StateChanged { - pane_id, - agent: Some(Agent::Pi), - state: AgentState::Idle, - }); - state.handle_app_event(AppEvent::HookStateReported { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - state: AgentState::Working, - message: None, - }); - state.handle_app_event(AppEvent::HookAgentReleased { - pane_id, - source: "herdr:pi".into(), - agent: Agent::Pi, - }); - - let pane = state.workspaces[0].panes.get(&pane_id).unwrap(); - assert!(pane.hook_authority.is_none()); - assert_eq!(pane.detected_agent, None); - assert_eq!(pane.fallback_state, AgentState::Unknown); - assert_eq!(pane.state, AgentState::Unknown); - } - - #[test] fn waiting_sound_plays_even_in_active_workspace() { assert_eq!( notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked), diff --git a/src/app/input.rs b/src/app/input.rs index e0f012e9..9a4c9aaf 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -32,6 +32,35 @@ use super::App; // Key handling // --------------------------------------------------------------------------- +fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> { + let kb = &state.keybinds; + if kb + .previous_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousWorkspace); + } + if kb + .next_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextWorkspace); + } + if kb + .previous_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousTab); + } + if kb + .next_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextTab); + } + None +} + impl App { pub(super) async fn handle_key(&mut self, key: TerminalKey) { match self.state.mode { @@ -42,7 +71,9 @@ impl App { Mode::Onboarding => self.handle_onboarding_key(key), Mode::ReleaseNotes => self.handle_release_notes_key(key), Mode::Navigate => handle_navigate_key(&mut self.state, key), - Mode::RenameSession => handle_rename_key(&mut self.state, key), + Mode::RenameWorkspace | Mode::RenameTab => { + handle_rename_key(&mut self.state, key) + } Mode::Resize => handle_resize_key(&mut self.state, key), Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key), Mode::ContextMenu => handle_context_menu_key(&mut self.state, key), @@ -223,6 +254,11 @@ impl App { let key_event = key.as_key_event(); + if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) { + execute_navigate_action(&mut self.state, action); + return; + } + if self.state.is_prefix(&key_event) { self.state.mode = Mode::Navigate; return; @@ -446,6 +482,13 @@ enum NavigateAction { NewWorkspace, RenameWorkspace, CloseWorkspace, + PreviousWorkspace, + NextWorkspace, + NewTab, + RenameTab, + PreviousTab, + NextTab, + CloseTab, SplitVertical, SplitHorizontal, ClosePane, @@ -465,6 +508,45 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) { return Some(NavigateAction::CloseWorkspace); } + if kb + .previous_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousWorkspace); + } + if kb + .next_workspace + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextWorkspace); + } + if key_matches(key, kb.new_tab.0, kb.new_tab.1) { + return Some(NavigateAction::NewTab); + } + if kb + .rename_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::RenameTab); + } + if kb + .previous_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::PreviousTab); + } + if kb + .next_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::NextTab); + } + if kb + .close_tab + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::CloseTab); + } if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) { return Some(NavigateAction::SplitVertical); } @@ -495,7 +577,7 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { NavigateAction::RenameWorkspace => { if !state.workspaces.is_empty() { state.name_input = state.workspaces[state.selected].display_name(); - state.mode = Mode::RenameSession; + state.mode = Mode::RenameWorkspace; } } NavigateAction::CloseWorkspace => { @@ -508,6 +590,38 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) { } } } + NavigateAction::PreviousWorkspace => { + state.previous_workspace(); + leave_navigate_mode(state); + } + NavigateAction::NextWorkspace => { + state.next_workspace(); + leave_navigate_mode(state); + } + NavigateAction::NewTab => { + state.request_new_tab = true; + leave_navigate_mode(state); + } + NavigateAction::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.mode = Mode::RenameTab; + } + } + } + NavigateAction::PreviousTab => { + state.previous_tab(); + leave_navigate_mode(state); + } + NavigateAction::NextTab => { + state.next_tab(); + leave_navigate_mode(state); + } + NavigateAction::CloseTab => { + state.close_tab(); + leave_navigate_mode(state); + } NavigateAction::SplitVertical => { state.split_pane(Direction::Horizontal); leave_navigate_mode(state); @@ -567,8 +681,20 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) { } else { state.name_input.trim().to_string() }; - if !new_name.is_empty() && !state.workspaces.is_empty() { - state.workspaces[state.selected].set_custom_name(new_name); + if !new_name.is_empty() { + match state.mode { + Mode::RenameWorkspace if !state.workspaces.is_empty() => { + state.workspaces[state.selected].set_custom_name(new_name); + } + Mode::RenameTab => { + if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) { + if let Some(tab) = ws.active_tab_mut() { + tab.set_custom_name(new_name); + } + } + } + _ => {} + } } state.name_input.clear(); state.mode = Mode::Navigate; @@ -577,6 +703,9 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) { state.name_input.clear(); state.mode = Mode::Navigate; } + KeyCode::Char('c') if key.modifiers == crossterm::event::KeyModifiers::CONTROL => { + state.name_input.clear(); + } KeyCode::Backspace => { state.name_input.pop(); } @@ -653,7 +782,7 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => { state.selected = ws_idx; state.name_input = state.workspaces[ws_idx].display_name(); - state.mode = Mode::RenameSession; + state.mode = Mode::RenameWorkspace; } (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => { state.selected = ws_idx; @@ -664,6 +793,35 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: state.mode = Mode::Navigate; } } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.request_new_tab = true; + state.mode = Mode::Terminal; + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + if let Some(ws) = state.workspaces.get(ws_idx) { + if let Some(name) = ws.active_tab_display_name() { + state.name_input = name; + state.mode = Mode::RenameTab; + } + } + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { + state.selected = ws_idx; + state.active = Some(ws_idx); + state.switch_tab(tab_idx); + state.close_tab(); + state.mode = if state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } (ContextMenuKind::Pane, Some("Split vertical")) => { state.split_pane(Direction::Horizontal); state.mode = Mode::Terminal; @@ -760,6 +918,30 @@ impl AppState { && row < button.y + button.height } + fn rename_modal_inner(&self) -> Option<Rect> { + self.onboarding_modal_inner(56, 7) + } + + fn rename_button_at(&self, col: u16, row: u16) -> Option<&'static str> { + let inner = self.rename_modal_inner()?; + if inner.height < 4 || inner.width < 28 { + return None; + } + let (save, clear, cancel) = crate::ui::rename_button_rects(inner); + if row != save.y { + return None; + } + if col >= save.x && col < save.x + save.width { + Some("save") + } else if col >= clear.x && col < clear.x + clear.width { + Some("clear") + } else if col >= cancel.x && col < cancel.x + cancel.width { + Some("cancel") + } else { + None + } + } + fn release_notes_body_rect(&self) -> Option<Rect> { let inner = self.release_notes_modal_inner()?; if inner.height < 8 || inner.width < 4 { @@ -1082,6 +1264,19 @@ impl AppState { return None; } + if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) { + match self.rename_button_at(mouse.column, mouse.row) { + Some("save") => handle_rename_key(self, KeyEvent::from(KeyCode::Enter)), + Some("clear") => self.name_input.clear(), + Some("cancel") => handle_rename_key(self, KeyEvent::from(KeyCode::Esc)), + None => { + handle_rename_key(self, KeyEvent::from(KeyCode::Esc)); + } + _ => {} + } + return None; + } + if self.mode == Mode::ContextMenu { let item_idx = self.context_menu_item_at(mouse.column, mouse.row); if let Some(menu) = self.context_menu.take() { @@ -1139,6 +1334,17 @@ impl AppState { } } + if let Some(tab_idx) = self.tab_at(mouse.column, mouse.row) { + self.switch_tab(tab_idx); + self.mode = Mode::Terminal; + return None; + } + if self.on_new_tab_button(mouse.column, mouse.row) { + self.request_new_tab = true; + self.mode = Mode::Terminal; + return None; + } + if in_sidebar { if self.sidebar_collapsed { let idx = (mouse.row - sidebar.y) as usize; @@ -1300,6 +1506,23 @@ impl AppState { } } + MouseEventKind::Down(MouseButton::Right) + if self.tab_at(mouse.column, mouse.row).is_some() => + { + if let (Some(ws_idx), Some(tab_idx)) = + (self.active, self.tab_at(mouse.column, mouse.row)) + { + self.switch_tab(tab_idx); + self.context_menu = Some(ContextMenuState { + kind: ContextMenuKind::Tab { ws_idx, tab_idx }, + x: mouse.column, + y: mouse.row, + selected: 0, + }); + self.mode = Mode::ContextMenu; + } + } + MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if self.pane_at(mouse.column, mouse.row).is_some() { self.context_menu = Some(ContextMenuState { @@ -1337,6 +1560,29 @@ impl AppState { } /// Find which workspace index a sidebar row belongs to (two-section layout). + fn tab_at(&self, col: u16, row: u16) -> Option<usize> { + self.view + .tab_hit_areas + .iter() + .enumerate() + .find_map(|(idx, area)| { + (row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width) + .then_some(idx) + }) + } + + fn on_new_tab_button(&self, col: u16, row: u16) -> bool { + let area = self.view.new_tab_hit_area; + area.width > 0 + && row >= area.y + && row < area.y + area.height + && col >= area.x + && col < area.x + area.width + } + fn workspace_at_row(&self, row: u16) -> Option<usize> { let sidebar = self.view.sidebar_rect; let total_h = sidebar.height as usize; @@ -1744,7 +1990,7 @@ mod tests { KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()), ); - assert_eq!(state.mode, Mode::RenameSession); + assert_eq!(state.mode, Mode::RenameWorkspace); assert_eq!(state.name_input, "test"); } diff --git a/src/app/mod.rs b/src/app/mod.rs index f6eb9af3..b69c324e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -186,6 +186,7 @@ impl App { mode, should_quit: false, request_new_workspace: false, + request_new_tab: false, request_complete_onboarding: false, name_input: String::new(), onboarding_step: 0, @@ -198,6 +199,9 @@ impl App { }), view: state::ViewState { sidebar_rect: Rect::default(), + tab_bar_rect: Rect::default(), + tab_hit_areas: Vec::new(), + new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), @@ -250,7 +254,7 @@ impl App { state .workspaces .get(idx) - .map(|ws| (idx, ws.layout.focused())) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); Self { @@ -319,6 +323,12 @@ impl App { needs_render = true; } + if self.state.request_new_tab { + self.state.request_new_tab = false; + self.create_tab(); + needs_render = true; + } + let now = Instant::now(); self.sync_animation_timer(now); @@ -682,7 +692,7 @@ impl App { self.state .workspaces .get(idx) - .map(|ws| (idx, ws.layout.focused())) + .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); if current_focus == self.last_focus { return; @@ -717,7 +727,7 @@ impl App { .workspaces .iter() .enumerate() - .find_map(|(ws_idx, ws)| ws.panes.get(&pane_id).map(|pane| (ws_idx, pane))) + .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) } fn public_workspace_id(&self, ws_idx: usize) -> String { @@ -1451,6 +1461,38 @@ impl App { } } + fn create_tab(&mut self) { + let initial_cwd = self + .state + .active + .and_then(|i| self.state.workspaces.get(i)) + .and_then(|ws| ws.focused_runtime()) + .and_then(|rt| rt.cwd()) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + if let Err(e) = self.create_tab_with_options(initial_cwd, true) { + error!(err = %e, "failed to create tab"); + } + } + + fn create_tab_with_options( + &mut self, + initial_cwd: std::path::PathBuf, + focus: bool, + ) -> std::io::Result<usize> { + let Some(ws_idx) = self.state.active else { + return self.create_workspace_with_options(initial_cwd, focus); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let ws = &mut self.state.workspaces[ws_idx]; + let idx = ws.create_tab(rows, cols, initial_cwd)?; + if focus { + ws.switch_tab(idx); + self.state.mode = Mode::Terminal; + } + Ok(idx) + } + fn create_workspace_with_options( &mut self, initial_cwd: std::path::PathBuf, @@ -1493,9 +1535,9 @@ impl App { )); }; Ok(ws - .layout - .pane_ids() - .into_iter() + .tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids().into_iter()) .filter_map(|pane_id| self.pane_info(ws_idx, pane_id)) .collect()) } else { @@ -1505,9 +1547,9 @@ impl App { .iter() .enumerate() .flat_map(|(ws_idx, ws)| { - ws.layout - .pane_ids() - .into_iter() + ws.tabs + .iter() + .flat_map(|tab| tab.layout.pane_ids().into_iter()) .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id)) }) .collect()) @@ -1520,12 +1562,16 @@ impl App { pane_id: crate::layout::PaneId, ) -> Option<crate::api::schema::PaneInfo> { let ws = self.state.workspaces.get(ws_idx)?; - let pane = ws.panes.get(&pane_id)?; - let runtime = ws.runtimes.get(&pane_id); + let pane = ws.pane_state(pane_id)?; + let runtime = ws.runtime(pane_id); + let focused = self.state.active == Some(ws_idx) + && ws + .focused_pane_id() + .is_some_and(|focused| focused == pane_id); Some(crate::api::schema::PaneInfo { pane_id: self.public_pane_id(ws_idx, pane_id)?, workspace_id: self.public_workspace_id(ws_idx), - focused: self.state.active == Some(ws_idx) && ws.layout.focused() == pane_id, + focused, cwd: runtime .and_then(|rt| rt.cwd()) .map(|cwd| cwd.display().to_string()), @@ -1541,7 +1587,7 @@ impl App { pane_id: crate::layout::PaneId, ) -> Option<(&crate::pane::PaneRuntime, String)> { let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtimes.get(&pane_id)?; + let runtime = ws.runtime(pane_id)?; Some((runtime, self.public_workspace_id(ws_idx))) } @@ -1554,7 +1600,7 @@ impl App { &crate::pane::PaneRuntime, )> { let ws = self.state.workspaces.get(ws_idx)?; - let runtime = ws.runtimes.get(&pane_id)?; + let runtime = ws.runtime(pane_id)?; Some((&runtime.sender, runtime)) } @@ -1566,7 +1612,7 @@ impl App { number: index + 1, label: ws.display_name(), focused: self.state.active == Some(index), - pane_count: ws.panes.len(), + pane_count: ws.public_pane_numbers.len(), agent_state: pane_agent_state(agg_state), } } diff --git a/src/app/state.rs b/src/app/state.rs index 49c740ae..5770154d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -324,6 +324,9 @@ impl Palette { /// Updated before each render, consumed by render and mouse handling. pub struct ViewState { pub sidebar_rect: Rect, + pub tab_bar_rect: Rect, + pub tab_hit_areas: Vec<Rect>, + pub new_tab_hit_area: Rect, pub terminal_area: Rect, pub pane_infos: Vec<PaneInfo>, pub split_borders: Vec<SplitBorder>, @@ -335,7 +338,8 @@ pub enum Mode { ReleaseNotes, Navigate, Terminal, - RenameSession, + RenameWorkspace, + RenameTab, Resize, ConfirmClose, ContextMenu, @@ -414,6 +418,7 @@ pub(crate) struct DragState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextMenuKind { Workspace { ws_idx: usize }, + Tab { ws_idx: usize, tab_idx: usize }, Pane, } @@ -429,6 +434,7 @@ impl ContextMenuState { pub fn items(&self) -> &'static [&'static str] { match self.kind { ContextMenuKind::Workspace { .. } => &["Rename", "Close"], + ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"], ContextMenuKind::Pane => &[ "Split vertical", "Split horizontal", @@ -469,6 +475,7 @@ pub struct AppState { pub mode: Mode, pub should_quit: bool, pub request_new_workspace: bool, + pub request_new_tab: bool, pub request_complete_onboarding: bool, pub name_input: String, pub onboarding_step: usize, @@ -559,6 +566,7 @@ impl AppState { mode: Mode::Navigate, should_quit: false, request_new_workspace: false, + request_new_tab: false, request_complete_onboarding: false, name_input: String::new(), onboarding_step: 0, @@ -566,6 +574,9 @@ impl AppState { release_notes: None, view: ViewState { sidebar_rect: Rect::default(), + tab_bar_rect: Rect::default(), + tab_hit_areas: Vec::new(), + new_tab_hit_area: Rect::default(), terminal_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), @@ -597,6 +608,14 @@ impl AppState { rename_workspace_label: "shift+n".into(), close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()), close_workspace_label: "d".into(), + previous_workspace: None, + next_workspace: None, + new_tab: (KeyCode::Char('c'), KeyModifiers::empty()), + new_tab_label: "c".into(), + rename_tab: None, + previous_tab: None, + next_tab: None, + close_tab: None, split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()), split_vertical_label: "v".into(), split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()), |
