diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/agents.rs | 127 | ||||
| -rw-r--r-- | src/app/api/integrations.rs | 46 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 353 | ||||
| -rw-r--r-- | src/app/api/responses.rs | 19 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 233 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 168 |
6 files changed, 946 insertions, 0 deletions
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs new file mode 100644 index 00000000..4077b38c --- /dev/null +++ b/src/app/api/agents.rs @@ -0,0 +1,127 @@ +use bytes::Bytes; + +use crate::api::schema::{ + AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat, + ReadSource, ResponseResult, +}; +use crate::app::App; + +use super::responses::{encode_error, encode_error_body, encode_success}; + +impl App { + pub(super) fn handle_agent_list(&mut self, id: String) -> String { + encode_success( + id, + ResponseResult::AgentList { + agents: self.collect_agent_infos(), + }, + ) + } + + pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String { + let agent = match self.agent_info_for_target(&target.target) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_focus(&mut self, id: String, target: AgentTarget) -> String { + let agent = match self.focus_agent_target(&target.target) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_rename(&mut self, id: String, params: AgentRenameParams) -> String { + let agent = match self.rename_agent_target(¶ms.target, params.name) { + Ok(agent) => agent, + Err(err) => return encode_error_body(id, self.agent_rename_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentInfo { agent }) + } + + pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String { + let (agent, argv) = match self.start_agent(params) { + Ok(started) => started, + Err(err) => return encode_error_body(id, self.agent_start_error_body(err)), + }; + + encode_success(id, ResponseResult::AgentStarted { agent, argv }) + } + + pub(super) fn handle_agent_read( + &mut self, + id: String, + params: crate::api::schema::AgentReadParams, + ) -> String { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some((pane, workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id) + else { + return agent_not_found(id, ¶ms.target); + }; + let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; + let text = match params.format { + ReadFormat::Text => match params.source { + ReadSource::Visible => pane.visible_text(), + ReadSource::Recent => pane.recent_text(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), + }, + ReadFormat::Ansi => match params.source { + ReadSource::Visible => pane.visible_ansi(), + ReadSource::Recent => pane.recent_ansi(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), + }, + }; + + encode_success( + id, + ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: self + .public_pane_id(resolved.ws_idx, resolved.pane_id) + .unwrap_or_else(|| params.target.clone()), + workspace_id, + tab_id: self + .public_tab_id(resolved.ws_idx, resolved.tab_idx) + .unwrap(), + source: params.source, + format: params.format, + text, + revision: 0, + truncated: false, + }, + }, + ) + } + + pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String { + let resolved = match self.resolve_terminal_target(¶ms.target) { + Ok(resolved) => resolved, + Err(err) => return encode_error_body(id, self.agent_target_error_body(err)), + }; + let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else { + return agent_not_found(id, ¶ms.target); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return encode_error(id, "agent_send_failed", err.to_string()); + } + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn agent_not_found(id: String, target: &str) -> String { + encode_error( + id, + "agent_not_found", + format!("agent target {target} not found"), + ) +} diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs new file mode 100644 index 00000000..0cca8cba --- /dev/null +++ b/src/app/api/integrations.rs @@ -0,0 +1,46 @@ +use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_integration_install( + &mut self, + id: String, + params: crate::api::schema::IntegrationInstallParams, + ) -> String { + let target = params.target; + let messages = match crate::integration::install_target(target) { + Ok(messages) => messages, + Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), + }; + + encode_success( + id, + ResponseResult::IntegrationInstall { + target, + details: IntegrationInstallResult { messages }, + }, + ) + } + + pub(super) fn handle_integration_uninstall( + &mut self, + id: String, + params: crate::api::schema::IntegrationUninstallParams, + ) -> String { + let target = params.target; + let messages = match crate::integration::uninstall_target(target) { + Ok(messages) => messages, + Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), + }; + + encode_success( + id, + ResponseResult::IntegrationUninstall { + target, + details: IntegrationUninstallResult { messages }, + }, + ) + } +} diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs new file mode 100644 index 00000000..b09ecb2a --- /dev/null +++ b/src/app/api/panes.rs @@ -0,0 +1,353 @@ +use bytes::Bytes; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams, + PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, + PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, + PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult, +}; +use crate::app::{App, Mode}; + +use super::super::api_helpers::{ + detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, + normalize_reported_agent_label, +}; +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { + let Some((ws_idx, target_pane_id)) = self.parse_pane_id(¶ms.target_pane_id) else { + return pane_not_found(id, ¶ms.target_pane_id); + }; + let (rows, cols) = self.state.estimate_pane_size(); + let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { + self.state.workspaces.get(ws_idx).and_then(|ws| { + let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?; + ws.tabs.get(tab_idx)?.cwd_for_pane( + target_pane_id, + &self.state.terminals, + &self.terminal_runtimes, + ) + }) + }); + 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 Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return pane_not_found(id, ¶ms.target_pane_id); + }; + let direction = match params.direction { + crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, + crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, + }; + let (target_tab_idx, new_pane) = match ws.split_pane( + target_pane_id, + direction, + rows, + cols, + split_cwd, + scrollback_limit_bytes, + host_terminal_theme, + &default_shell, + params.focus, + ) { + Some(Ok(result)) => result, + Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()), + None => return pane_not_found(id, ¶ms.target_pane_id), + }; + if params.focus { + self.state.switch_workspace(ws_idx); + self.state.switch_tab(target_tab_idx); + self.state.mode = Mode::Terminal; + } + self.terminal_runtimes + .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .terminals + .insert(new_pane.terminal.id.clone(), new_pane.terminal); + self.schedule_session_save(); + let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { pane: pane.clone() }, + }); + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_list(&mut self, id: String, params: PaneListParams) -> String { + match self.collect_panes_for_workspace(params.workspace_id.as_deref()) { + Ok(panes) => encode_success(id, ResponseResult::PaneList { panes }), + Err((code, message)) => encode_error(id, &code, message), + } + } + + pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal_id) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.terminal_id(pane_id)) + .cloned() + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + match params.label.map(|label| label.trim().to_string()) { + Some(label) if !label.is_empty() => terminal.set_manual_label(label), + _ => terminal.clear_manual_label(), + } + self.state.mark_session_dirty(); + let pane = self.pane_info(ws_idx, pane_id).unwrap(); + + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_read(&mut self, id: String, params: PaneReadParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(tab_idx) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let requested_lines = params.lines.unwrap_or(80).min(1000) as usize; + let text = match params.format { + ReadFormat::Text => match params.source { + ReadSource::Visible => pane.visible_text(), + ReadSource::Recent => pane.recent_text(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines), + }, + ReadFormat::Ansi => match params.source { + ReadSource::Visible => pane.visible_ansi(), + ReadSource::Recent => pane.recent_ansi(requested_lines), + ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines), + }, + }; + + encode_success( + id, + ResponseResult::PaneRead { + read: PaneReadResult { + pane_id: params.pane_id, + workspace_id, + tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), + source: params.source, + format: params.format, + text, + revision: 0, + truncated: false, + }, + }, + ) + } + + pub(super) fn handle_pane_report_agent( + &mut self, + id: String, + params: PaneReportAgentParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { + return invalid_agent(id); + }; + self.handle_internal_event(crate::events::AppEvent::HookStateReported { + pane_id, + source: params.source, + agent_label, + state: detect_state_from_api(params.state), + message: params.message, + custom_status: normalize_custom_status(params.custom_status), + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_clear_agent_authority( + &mut self, + id: String, + params: PaneClearAgentAuthorityParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { + pane_id, + source: params.source, + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_release_agent( + &mut self, + id: String, + params: PaneReleaseAgentParams, + ) -> String { + let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { + return invalid_agent(id); + }; + self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { + pane_id, + source: params.source, + known_agent: crate::detect::parse_agent_label(&agent_label), + agent_label, + seq: params.seq, + }); + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_text( + &mut self, + id: String, + params: PaneSendTextParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_input( + &mut self, + id: String, + params: PaneSendInputParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded_keys) => encoded_keys, + Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), + }; + if !params.text.is_empty() { + let text_bytes = encode_api_text(runtime, ¶ms.text); + if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + for bytes in encoded_keys { + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let workspace_id = self.state.workspaces[ws_idx].id.clone(); + let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); + let should_close_workspace = { + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return pane_not_found(id, &target.pane_id); + }; + ws.close_pane(pane_id) + }; + if should_close_workspace { + self.state.selected = ws_idx; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::PaneClosed, + data: EventData::PaneClosed { + pane_id: target.pane_id.clone(), + workspace_id: workspace_id.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { workspace_id }, + }); + } else { + self.state.remove_unattached_terminal_ids(terminal_id); + self.shutdown_detached_terminal_runtimes(); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::PaneClosed, + data: EventData::PaneClosed { + pane_id: target.pane_id, + workspace_id, + }, + }); + } + + encode_success(id, ResponseResult::Ok {}) + } + + pub(super) fn handle_pane_send_keys( + &mut self, + id: String, + params: PaneSendKeysParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { + Ok(encoded_keys) => encoded_keys, + Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), + }; + for bytes in encoded_keys { + if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { + return encode_error(id, "pane_send_failed", err.to_string()); + } + } + + encode_success(id, ResponseResult::Ok {}) + } +} + +fn pane_not_found(id: String, pane_id: &str) -> String { + encode_error(id, "pane_not_found", format!("pane {pane_id} not found")) +} + +fn invalid_agent(id: String) -> String { + encode_error(id, "invalid_agent", "agent label must not be empty") +} diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs new file mode 100644 index 00000000..7c871c37 --- /dev/null +++ b/src/app/api/responses.rs @@ -0,0 +1,19 @@ +use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse}; + +pub(super) fn encode_success(id: String, result: ResponseResult) -> String { + serde_json::to_string(&SuccessResponse { id, result }).unwrap() +} + +pub(super) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String { + encode_error_body( + id, + ErrorBody { + code: code.into(), + message: message.into(), + }, + ) +} + +pub(super) fn encode_error_body(id: String, error: ErrorBody) -> String { + serde_json::to_string(&ErrorResponse { id, error }).unwrap() +} diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs new file mode 100644 index 00000000..7978a17f --- /dev/null +++ b/src/app/api/tabs.rs @@ -0,0 +1,233 @@ +use std::path::PathBuf; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, ResponseResult, TabCreateParams, TabListParams, + 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, + } = 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) + .or_else(|| { + self.state + .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx) + .and_then(|rt| rt.cwd()) + }) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")); + 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 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, + &default_shell, + ) + }); + match result { + Ok((tab_idx, terminal, runtime)) => { + self.terminal_runtimes.insert(terminal.id.clone(), runtime); + self.state.terminals.insert(terminal.id.clone(), terminal); + 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(|| format!("{}:{}", 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(ws_idx); + self.state.switch_tab(tab_idx); + self.state.mode = Mode::Terminal; + } + self.schedule_session_save(); + let tab = self.tab_info(ws_idx, tab_idx).unwrap(); + let root_pane = self + .root_pane_info(ws_idx, tab_idx) + .expect("new tab should have a root pane"); + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab: tab.clone() }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { + pane: root_pane.clone(), + }, + }); + 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(ws_idx); + self.state.switch_tab(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(|| format!("{}:{}", 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_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 terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx); + 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), + ); + } + 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: target.tab_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + + 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")) +} diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs new file mode 100644 index 00000000..2f8c1a28 --- /dev/null +++ b/src/app/api/workspaces.rs @@ -0,0 +1,168 @@ +use std::path::PathBuf; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, + WorkspaceRenameParams, WorkspaceTarget, +}; +use crate::app::App; + +use super::responses::{encode_error, encode_success}; + +impl App { + pub(super) fn handle_workspace_list(&mut self, id: String) -> String { + encode_success( + id, + ResponseResult::WorkspaceList { + workspaces: self + .state + .workspaces + .iter() + .enumerate() + .map(|(idx, _)| self.workspace_info(idx)) + .collect(), + }, + ) + } + + pub(super) fn handle_workspace_get(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + let Some(_) = self.state.workspaces.get(index) else { + return workspace_not_found(id, &target.workspace_id); + }; + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_create( + &mut self, + id: String, + params: WorkspaceCreateParams, + ) -> String { + let cwd = params + .cwd + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from("/")); + match self.create_workspace_with_options(cwd, params.focus) { + Ok(index) => { + if let Some(label) = params.label { + if let Some(workspace) = self.state.workspaces.get_mut(index) { + workspace.set_custom_name(label); + crate::logging::workspace_renamed(&workspace.id); + } + } + let workspace = self.workspace_info(index); + let tab = self + .tab_info(index, 0) + .expect("new workspace should have an initial tab"); + let root_pane = self + .root_pane_info(index, 0) + .expect("new workspace should have an initial root pane"); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceCreated, + data: EventData::WorkspaceCreated { + workspace: workspace.clone(), + }, + }); + self.emit_event(EventEnvelope { + event: EventKind::TabCreated, + data: EventData::TabCreated { tab: tab.clone() }, + }); + self.emit_event(EventEnvelope { + event: EventKind::PaneCreated, + data: EventData::PaneCreated { + pane: root_pane.clone(), + }, + }); + encode_success( + id, + self.workspace_created_result(index) + .expect("new workspace should produce a complete create response"), + ) + } + Err(err) => encode_error(id, "workspace_create_failed", err.to_string()), + } + } + + pub(super) fn handle_workspace_focus(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, &target.workspace_id); + } + self.state.switch_workspace(index); + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_rename( + &mut self, + id: String, + params: WorkspaceRenameParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + let Some(ws) = self.state.workspaces.get_mut(index) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + ws.set_custom_name(params.label.clone()); + crate::logging::workspace_renamed(&ws.id); + self.schedule_session_save(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceRenamed, + data: EventData::WorkspaceRenamed { + workspace_id: self.public_workspace_id(index), + label: params.label, + }, + }); + + encode_success( + id, + ResponseResult::WorkspaceInfo { + workspace: self.workspace_info(index), + }, + ) + } + + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { + let Some(index) = self.parse_workspace_id(&target.workspace_id) else { + return workspace_not_found(id, &target.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, &target.workspace_id); + } + self.state.selected = index; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: target.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"), + ) +} |
