use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, TabCreateParams, TabListParams, TabMoveParams, TabRenameParams, TabTarget, }; use crate::app::{App, Mode}; use super::responses::{encode_error, encode_success}; impl App { pub(super) fn handle_tab_list(&mut self, id: String, params: TabListParams) -> String { let tabs = if let Some(workspace_id) = params.workspace_id { let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { return workspace_not_found(id, &workspace_id); }; let Some(ws) = self.state.workspaces.get(ws_idx) else { return workspace_not_found(id, &workspace_id); }; (0..ws.tabs.len()) .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx)) .collect() } else { let mut tabs = Vec::new(); for (ws_idx, ws) in self.state.workspaces.iter().enumerate() { for tab_idx in 0..ws.tabs.len() { if let Some(tab) = self.tab_info(ws_idx, tab_idx) { tabs.push(tab); } } } tabs }; encode_success(id, ResponseResult::TabList { tabs }) } pub(super) fn handle_tab_get(&mut self, id: String, target: TabTarget) -> String { let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { return tab_not_found(id, &target.tab_id); }; let Some(tab) = self.tab_info(ws_idx, tab_idx) else { return tab_not_found(id, &target.tab_id); }; encode_success(id, ResponseResult::TabInfo { tab }) } pub(super) fn handle_tab_create(&mut self, id: String, params: TabCreateParams) -> String { let TabCreateParams { workspace_id, cwd, focus, label, env, } = params; let ws_idx = if let Some(workspace_id) = workspace_id { let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { return workspace_not_found(id, &workspace_id); }; ws_idx } else if let Some(active) = self.state.active { active } else { return encode_error(id, "workspace_not_found", "no active workspace"); }; let cwd = cwd.map(PathBuf::from).unwrap_or_else(|| { let follow_cwd = self .state .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) .and_then(|rt| rt.cwd()); self.resolve_new_terminal_cwd(follow_cwd) }); let (rows, cols) = self.state.estimate_pane_size(); let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; let extra_env = match super::env::normalize_launch_env(env) { Ok(env) => env, Err((code, message)) => return encode_error(id, &code, message), }; let result = self .state .workspaces .get_mut(ws_idx) .ok_or_else(|| std::io::Error::other("workspace disappeared")) .and_then(|ws| { ws.create_tab( rows, cols, cwd, scrollback_limit_bytes, host_terminal_theme, crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode), extra_env, ) }); match result { Ok((tab_idx, terminal, runtime)) => { self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); self.state.remove_alias_shadowed_by_new_pane( self.state.workspaces[ws_idx].tabs[tab_idx].root_pane, ); if let Some(label) = label { let workspace_id = self.state.workspaces[ws_idx].id.clone(); let tab_id = self.public_tab_id(ws_idx, tab_idx).unwrap_or_else(|| { crate::workspace::public_tab_id_for_number(&workspace_id, tab_idx + 1) }); if let Some(tab) = self .state .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx)) { tab.set_custom_name(label); crate::logging::tab_renamed(&workspace_id, &tab_id); } } if focus { self.state.switch_workspace_tab(ws_idx, tab_idx); self.state.mode = Mode::Terminal; } self.schedule_session_save(); self.emit_tab_created_events(ws_idx, tab_idx); encode_success( id, self.tab_created_result(ws_idx, tab_idx) .expect("new tab should produce a complete create response"), ) } Err(err) => encode_error(id, "tab_create_failed", err.to_string()), } } pub(super) fn handle_tab_focus(&mut self, id: String, target: TabTarget) -> String { let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { return tab_not_found(id, &target.tab_id); }; self.state.switch_workspace_tab(ws_idx, tab_idx); let tab = self.tab_info(ws_idx, tab_idx).unwrap(); encode_success(id, ResponseResult::TabInfo { tab }) } pub(super) fn handle_tab_rename(&mut self, id: String, params: TabRenameParams) -> String { let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { return tab_not_found(id, ¶ms.tab_id); }; let workspace_id = self.state.workspaces[ws_idx].id.clone(); let tab_id = self.public_tab_id(ws_idx, tab_idx).unwrap_or_else(|| { crate::workspace::public_tab_id_for_number(&workspace_id, tab_idx + 1) }); let Some(tab) = self .state .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx)) else { return tab_not_found(id, ¶ms.tab_id); }; tab.set_custom_name(params.label.clone()); crate::logging::tab_renamed(&workspace_id, &tab_id); self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::TabRenamed, data: EventData::TabRenamed { tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), workspace_id: self.public_workspace_id(ws_idx), label: params.label, }, }); let tab = self.tab_info(ws_idx, tab_idx).unwrap(); encode_success(id, ResponseResult::TabInfo { tab }) } pub(super) fn handle_tab_move(&mut self, id: String, params: TabMoveParams) -> String { let Some((ws_idx, tab_idx)) = self.parse_tab_id(¶ms.tab_id) else { return tab_not_found(id, ¶ms.tab_id); }; let Some(ws) = self.state.workspaces.get(ws_idx) else { return tab_not_found(id, ¶ms.tab_id); }; if params.insert_index > ws.tabs.len() { return encode_error( id, "tab_move_failed", format!("insert_index {} is out of bounds", params.insert_index), ); } let moved = self .state .workspaces .get_mut(ws_idx) .is_some_and(|ws| ws.move_tab(tab_idx, params.insert_index)); if moved { self.schedule_session_save(); if self.state.active == Some(ws_idx) { self.state.tab_scroll_follow_active = true; self.state.refresh_tab_bar_view(); } } encode_success( id, ResponseResult::TabList { tabs: self .state .workspaces .get(ws_idx) .map(|ws| { (0..ws.tabs.len()) .filter_map(|idx| self.tab_info(ws_idx, idx)) .collect() }) .unwrap_or_default(), }, ) } pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String { let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { return tab_not_found(id, &target.tab_id); }; let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { return tab_not_found(id, &target.tab_id); }; let workspace_id = self.public_workspace_id(ws_idx); let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); let pane_ids = self .state .workspaces .get(ws_idx) .and_then(|ws| ws.tabs.get(tab_idx)) .map(|tab| tab.layout.pane_ids()) .unwrap_or_default(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return tab_not_found(id, &target.tab_id); }; if ws.tabs.len() <= 1 { return encode_error( id, "tab_close_failed", "cannot close the last tab in a workspace", ); } if !ws.close_tab(tab_idx) { return encode_error( id, "tab_close_failed", format!("tab {} could not be closed", target.tab_id), ); } for pane_id in pane_ids { self.state.plugin_panes.remove(&pane_id); } self.state.remove_unattached_terminal_ids(terminal_ids); self.shutdown_detached_terminal_runtimes(); self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::TabClosed, data: EventData::TabClosed { tab_id, workspace_id, }, }); encode_success(id, ResponseResult::Ok {}) } } fn workspace_not_found(id: String, workspace_id: &str) -> String { encode_error( id, "workspace_not_found", format!("workspace {workspace_id} not found"), ) } fn tab_not_found(id: String, tab_id: &str) -> String { encode_error(id, "tab_not_found", format!("tab {tab_id} not found")) } #[cfg(test)] mod tests { use super::*; use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; #[test] fn api_tab_move_reorders_tabs_in_target_workspace() { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), true, None, api_rx, crate::api::EventHub::default(), ); let mut workspace = Workspace::test_new("tabs"); workspace.test_add_tab(Some("two")); workspace.test_add_tab(Some("three")); app.state.workspaces = vec![workspace]; app.state.active = Some(0); app.state.selected = 0; let moved_root = app.state.workspaces[0].tabs[0].root_pane; let moved_id = app.public_tab_id(0, 0).unwrap(); let response = app.handle_tab_move( "req".into(), TabMoveParams { tab_id: moved_id, insert_index: 3, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::TabList { tabs } = success.result else { panic!("expected tab list"); }; assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); assert_eq!(tabs[2].tab_id, app.public_tab_id(0, 2).unwrap()); } }