aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/agents.rs127
-rw-r--r--src/app/api/integrations.rs46
-rw-r--r--src/app/api/panes.rs353
-rw-r--r--src/app/api/responses.rs19
-rw-r--r--src/app/api/tabs.rs233
-rw-r--r--src/app/api/workspaces.rs168
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(&params.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(&params.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, &params.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(&params.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, &params.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(&params.target_pane_id) else {
+ return pane_not_found(id, &params.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, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let encoded_keys = match encode_api_keys(runtime, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let encoded_keys = match encode_api_keys(runtime, &params.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(&params.tab_id) else {
+ return tab_not_found(id, &params.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, &params.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(&params.workspace_id) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ let Some(ws) = self.state.workspaces.get_mut(index) else {
+ return workspace_not_found(id, &params.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"),
+ )
+}