diff --git a/src/api/mod.rs b/src/api/mod.rs index 62247aa7..b285eb3d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -27,6 +27,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::WorkspaceCreate(_) | Method::WorkspaceFocus(_) | Method::WorkspaceRename(_) + | Method::WorkspaceMove(_) | Method::WorkspaceClose(_) | Method::WorktreeCreate(_) | Method::WorktreeOpen(_) @@ -34,8 +35,10 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::TabCreate(_) | Method::TabFocus(_) | Method::TabRename(_) + | Method::TabMove(_) | Method::TabClose(_) | Method::LayoutApply(_) + | Method::LayoutSetSplitRatio(_) | Method::AgentRename(_) | Method::AgentFocus(_) | Method::AgentStart(_) @@ -45,6 +48,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneZoom(_) | Method::PaneFocusDirection(_) | Method::PaneResize(_) + | Method::PaneFocus(_) | Method::PaneRename(_) | Method::PaneReportAgent(_) | Method::PaneReportAgentSession(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index b2d6f62e..c3349c04 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -69,6 +69,8 @@ pub enum Method { WorkspaceFocus(WorkspaceTarget), #[serde(rename = "workspace.rename")] WorkspaceRename(WorkspaceRenameParams), + #[serde(rename = "workspace.move")] + WorkspaceMove(WorkspaceMoveParams), #[serde(rename = "workspace.close")] WorkspaceClose(WorkspaceTarget), #[serde(rename = "worktree.list")] @@ -89,6 +91,8 @@ pub enum Method { TabFocus(TabTarget), #[serde(rename = "tab.rename")] TabRename(TabRenameParams), + #[serde(rename = "tab.move")] + TabMove(TabMoveParams), #[serde(rename = "tab.close")] TabClose(TabTarget), #[serde(rename = "agent.list")] @@ -123,6 +127,8 @@ pub enum Method { LayoutExport(LayoutExportParams), #[serde(rename = "layout.apply")] LayoutApply(LayoutApplyParams), + #[serde(rename = "layout.set_split_ratio")] + LayoutSetSplitRatio(LayoutSetSplitRatioParams), #[serde(rename = "pane.neighbor")] PaneNeighbor(PaneNeighborParams), #[serde(rename = "pane.edges")] @@ -137,6 +143,8 @@ pub enum Method { PaneCurrent(PaneCurrentParams), #[serde(rename = "pane.get")] PaneGet(PaneTarget), + #[serde(rename = "pane.focus")] + PaneFocus(PaneTarget), #[serde(rename = "pane.rename")] PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 9bd8103d..55241131 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -126,6 +126,16 @@ pub struct LayoutApplyParams { pub root: LayoutNode, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayoutSetSplitRatioParams { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, + pub path: Vec, + pub ratio: f32, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LayoutDescription { pub workspace_id: String, diff --git a/src/api/schema/tabs.rs b/src/api/schema/tabs.rs index 4be94c3b..41af5c6b 100644 --- a/src/api/schema/tabs.rs +++ b/src/api/schema/tabs.rs @@ -30,6 +30,12 @@ pub struct TabRenameParams { pub label: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TabMoveParams { + pub tab_id: String, + pub insert_index: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TabInfo { pub tab_id: String, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index c8c8adbb..d9ebe30b 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -702,6 +702,58 @@ fn layout_export_apply_round_trip() { assert_eq!(restored, response); } +#[test] +fn authority_mutation_requests_round_trip() { + let workspace_move = Request { + id: "move_ws".into(), + method: Method::WorkspaceMove(WorkspaceMoveParams { + workspace_id: "w1".into(), + insert_index: 2, + }), + }; + let json = serde_json::to_value(&workspace_move).unwrap(); + assert_eq!(json["method"], "workspace.move"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, workspace_move); + + let tab_move = Request { + id: "move_tab".into(), + method: Method::TabMove(TabMoveParams { + tab_id: "w1:1".into(), + insert_index: 1, + }), + }; + let json = serde_json::to_value(&tab_move).unwrap(); + assert_eq!(json["method"], "tab.move"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, tab_move); + + let pane_focus = Request { + id: "focus_pane".into(), + method: Method::PaneFocus(PaneTarget { + pane_id: "w1:1".into(), + }), + }; + let json = serde_json::to_value(&pane_focus).unwrap(); + assert_eq!(json["method"], "pane.focus"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, pane_focus); + + let split_ratio = Request { + id: "set_ratio".into(), + method: Method::LayoutSetSplitRatio(LayoutSetSplitRatioParams { + tab_id: Some("w1:1".into()), + pane_id: None, + path: vec![false, true], + ratio: 0.6, + }), + }; + let json = serde_json::to_value(&split_ratio).unwrap(); + assert_eq!(json["method"], "layout.set_split_ratio"); + let restored: Request = serde_json::from_value(json).unwrap(); + assert_eq!(restored, split_ratio); +} + #[test] fn create_response_round_trips_with_root_pane() { let response = SuccessResponse { diff --git a/src/api/schema/workspaces.rs b/src/api/schema/workspaces.rs index d15cf09b..be5ef0b4 100644 --- a/src/api/schema/workspaces.rs +++ b/src/api/schema/workspaces.rs @@ -22,6 +22,12 @@ pub struct WorkspaceRenameParams { pub label: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceMoveParams { + pub workspace_id: String, + pub insert_index: usize, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkspaceInfo { pub workspace_id: String, diff --git a/src/api/server.rs b/src/api/server.rs index e9e2b01b..039ede12 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -286,6 +286,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::WorkspaceGet(_) => "workspace.get", Method::WorkspaceFocus(_) => "workspace.focus", Method::WorkspaceRename(_) => "workspace.rename", + Method::WorkspaceMove(_) => "workspace.move", Method::WorkspaceClose(_) => "workspace.close", Method::WorktreeList(_) => "worktree.list", Method::WorktreeCreate(_) => "worktree.create", @@ -296,6 +297,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::TabGet(_) => "tab.get", Method::TabFocus(_) => "tab.focus", Method::TabRename(_) => "tab.rename", + Method::TabMove(_) => "tab.move", Method::TabClose(_) => "tab.close", Method::AgentList(_) => "agent.list", Method::AgentGet(_) => "agent.get", @@ -313,6 +315,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneProcessInfo(_) => "pane.process_info", Method::LayoutExport(_) => "layout.export", Method::LayoutApply(_) => "layout.apply", + Method::LayoutSetSplitRatio(_) => "layout.set_split_ratio", Method::PaneNeighbor(_) => "pane.neighbor", Method::PaneEdges(_) => "pane.edges", Method::PaneFocusDirection(_) => "pane.focus_direction", @@ -320,6 +323,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneList(_) => "pane.list", Method::PaneCurrent(_) => "pane.current", Method::PaneGet(_) => "pane.get", + Method::PaneFocus(_) => "pane.focus", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", diff --git a/src/app/actions.rs b/src/app/actions.rs index 92027b6a..56f6e789 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -258,7 +258,10 @@ impl AppState { }) } - fn pane_focus_target_indices(&self, target: &PaneFocusTarget) -> Option<(usize, usize)> { + pub(crate) fn pane_focus_target_indices( + &self, + target: &PaneFocusTarget, + ) -> Option<(usize, usize)> { let ws_idx = self .workspaces .iter() @@ -1231,16 +1234,6 @@ impl AppState { self.refresh_tab_bar_view(); } - pub fn move_tab(&mut self, source_idx: usize, insert_idx: usize) { - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - if ws.move_tab(source_idx, insert_idx) { - self.mark_session_dirty(); - self.tab_scroll_follow_active = true; - self.refresh_tab_bar_view(); - } - } - } - pub fn next_tab(&mut self) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if !ws.tabs.is_empty() { @@ -1315,7 +1308,7 @@ impl AppState { self.focus_agent_entry(target_idx); } - fn ensure_agent_panel_entry_visible(&mut self, idx: usize) { + pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) { if self.sidebar_collapsed { return; } @@ -1486,7 +1479,7 @@ impl AppState { } } - fn refresh_tab_bar_view(&mut self) { + pub(crate) fn refresh_tab_bar_view(&mut self) { let area = self.view.tab_bar_rect; let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else { self.tab_scroll = 0; diff --git a/src/app/api.rs b/src/app/api.rs index 6a5d1f8e..cfff3619 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -24,6 +24,36 @@ enum RuntimeExitAction { } impl App { + pub(crate) fn dispatch_tui_api_request( + &mut self, + id: &'static str, + method: crate::api::schema::Method, + ) -> String { + self.handle_api_request(crate::api::schema::Request { + id: id.to_string(), + method, + }) + } + + pub(crate) fn dispatch_tui_deferred_api_request( + &mut self, + id: &'static str, + method: crate::api::schema::Method, + ) -> Option { + let (respond_to, response_rx) = std::sync::mpsc::channel(); + if !self.handle_deferred_worktree_api_request( + crate::api::schema::Request { + id: id.to_string(), + method, + }, + respond_to, + ) { + return None; + } + + response_rx.try_recv().ok() + } + pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { #[cfg(not(test))] @@ -817,6 +847,9 @@ impl App { Method::WorkspaceRename(params) => { return self.handle_workspace_rename(request.id, params); } + Method::WorkspaceMove(params) => { + return self.handle_workspace_move(request.id, params); + } Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target) } @@ -843,6 +876,7 @@ impl App { Method::TabCreate(params) => return self.handle_tab_create(request.id, params), Method::TabFocus(target) => return self.handle_tab_focus(request.id, target), Method::TabRename(params) => return self.handle_tab_rename(request.id, params), + Method::TabMove(params) => return self.handle_tab_move(request.id, params), Method::TabClose(target) => return self.handle_tab_close(request.id, target), Method::AgentList(_) => return self.handle_agent_list(request.id), Method::AgentGet(target) => return self.handle_agent_get(request.id, target), @@ -862,6 +896,9 @@ impl App { } Method::LayoutExport(params) => return self.handle_layout_export(request.id, params), Method::LayoutApply(params) => return self.handle_layout_apply(request.id, params), + Method::LayoutSetSplitRatio(params) => { + return self.handle_layout_set_split_ratio(request.id, params); + } Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params), Method::PaneEdges(params) => return self.handle_pane_edges(request.id, params), Method::PaneFocusDirection(params) => { @@ -871,6 +908,7 @@ impl App { Method::PaneList(params) => return self.handle_pane_list(request.id, params), Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), + Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneReportAgent(params) => { diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 2b83d027..709d9f39 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -4,7 +4,7 @@ use ratatui::layout::Direction; use crate::api::schema::{ EventData, EventEnvelope, EventKind, LayoutApplyParams, LayoutDescription, LayoutExportParams, - LayoutNode, LayoutPane, ResponseResult, SplitDirection, + LayoutNode, LayoutPane, LayoutSetSplitRatioParams, ResponseResult, SplitDirection, }; use crate::app::{App, Mode}; use crate::layout::{Node, PaneId}; @@ -214,6 +214,38 @@ impl App { encode_success(id, ResponseResult::LayoutApply { layout }) } + pub(super) fn handle_layout_set_split_ratio( + &mut self, + id: String, + params: LayoutSetSplitRatioParams, + ) -> String { + if !params.ratio.is_finite() { + return encode_error(id, "invalid_ratio", "ratio must be finite"); + } + let Some((ws_idx, tab_idx)) = self.resolve_layout_export_target(&LayoutExportParams { + tab_id: params.tab_id, + pane_id: params.pane_id, + }) else { + return encode_error(id, "layout_not_found", "layout target not found"); + }; + + let changed = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + .is_some_and(|tab| tab.layout.set_ratio_at(¶ms.path, params.ratio)); + if !changed { + return encode_error(id, "split_not_found", "split path not found"); + } + + self.schedule_session_save(); + let Some(layout) = self.layout_description(ws_idx, tab_idx) else { + return encode_error(id, "layout_not_found", "layout unavailable"); + }; + encode_success(id, ResponseResult::LayoutApply { layout }) + } + fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> { match (params.tab_id.as_deref(), params.pane_id.as_deref()) { (Some(_), Some(_)) => None, @@ -636,6 +668,49 @@ mod tests { assert_eq!(pane.pane_id, Some(app.public_pane_id(0, right).unwrap())); } + #[test] + fn layout_set_split_ratio_updates_existing_split() { + let mut app = app_with_workspace(); + app.state.workspaces[0].test_split(Direction::Horizontal); + + let response = app.handle_layout_set_split_ratio( + "req".into(), + LayoutSetSplitRatioParams { + tab_id: None, + pane_id: None, + path: vec![], + ratio: 0.72, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::LayoutApply { layout } = success.result else { + panic!("expected layout apply response"); + }; + let LayoutNode::Split { ratio, .. } = layout.root else { + panic!("expected split layout root"); + }; + assert!((ratio - 0.72).abs() < f32::EPSILON); + } + + #[test] + fn layout_set_split_ratio_rejects_missing_split() { + let mut app = app_with_workspace(); + + let response = app.handle_layout_set_split_ratio( + "req".into(), + LayoutSetSplitRatioParams { + tab_id: None, + pane_id: None, + path: vec![], + ratio: 0.72, + }, + ); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "split_not_found"); + } + #[tokio::test] async fn layout_apply_replaces_tab_with_requested_tree() { let mut app = app_with_workspace(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 983db238..b2f5030a 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -161,6 +161,23 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_focus(&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(_tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + + self.state.focus_pane_in_workspace(ws_idx, pane_id); + self.state.mode = Mode::Terminal; + + 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_layout(&mut self, id: String, params: PaneLayoutParams) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); @@ -3203,6 +3220,51 @@ mod tests { assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); } + #[test] + fn api_pane_focus_focuses_direct_target_across_tabs_and_workspaces() { + let mut app = app_with_linked_worktree(); + app.state.workspaces.push(Workspace::test_new("other")); + let target_tab_idx = app.state.workspaces[1].test_add_tab(Some("target")); + app.state.workspaces[1].switch_tab(target_tab_idx); + let target_pane = app.state.workspaces[1].tabs[target_tab_idx].root_pane; + app.state.ensure_test_terminals(); + let target_public = app.public_pane_id(1, target_pane).unwrap(); + app.state.switch_workspace(0); + assert_eq!(app.state.active, Some(0)); + + let response = app.handle_pane_focus( + "req".into(), + crate::api::schema::PaneTarget { + pane_id: target_public.clone(), + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info response"); + }; + assert_eq!(pane.pane_id, target_public); + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.workspaces[1].active_tab, target_tab_idx); + assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn api_pane_focus_rejects_invalid_pane_id() { + let mut app = app_with_linked_worktree(); + + let response = app.handle_pane_focus( + "req".into(), + crate::api::schema::PaneTarget { + pane_id: "pane_missing".into(), + }, + ); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "pane_not_found"); + } + #[test] fn api_pane_focus_direction_no_neighbor_is_noop() { let mut app = app_with_linked_worktree(); diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 6c14a330..1ffcafdd 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, TabCreateParams, TabListParams, - TabRenameParams, TabTarget, + TabMoveParams, TabRenameParams, TabTarget, }; use crate::app::{App, Mode}; @@ -175,6 +175,51 @@ impl App { 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); @@ -237,3 +282,44 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { 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()); + } +} diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index d1d7bf5c..56427463 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceRenameParams, WorkspaceTarget, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, }; use crate::app::App; @@ -121,6 +121,41 @@ impl App { ) } + pub(super) fn handle_workspace_move( + &mut self, + id: String, + params: WorkspaceMoveParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, ¶ms.workspace_id); + } + if params.insert_index > self.state.workspaces.len() { + return encode_error( + id, + "workspace_move_failed", + format!("insert_index {} is out of bounds", params.insert_index), + ); + } + + self.state.move_workspace(index, params.insert_index); + + encode_success( + id, + ResponseResult::WorkspaceList { + workspaces: self + .state + .workspaces + .iter() + .enumerate() + .map(|(idx, _)| self.workspace_info(idx)) + .collect(), + }, + ) + } + 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); @@ -241,4 +276,39 @@ mod tests { ) })); } + + #[test] + fn api_workspace_move_reorders_workspaces() { + 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(), + ); + app.state.workspaces = vec![ + Workspace::test_new("one"), + Workspace::test_new("two"), + Workspace::test_new("three"), + ]; + app.state.active = Some(0); + app.state.selected = 0; + let moved_id = app.public_workspace_id(0); + + let response = app.handle_workspace_move( + "req".into(), + WorkspaceMoveParams { + workspace_id: moved_id.clone(), + insert_index: 3, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorkspaceList { workspaces } = success.result else { + panic!("expected workspace list"); + }; + assert_eq!(workspaces[2].workspace_id, moved_id); + assert_eq!(app.state.workspaces[2].display_name(), "one"); + } } diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index f63a47fd..516e9455 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -366,6 +366,12 @@ impl App { self.pending_api_worktree_creates.remove(&checkout_key); if let Err(err) = result.result { + if let Some(create) = &mut self.state.worktree_create { + if create.checkout_path == result.path { + create.creating = false; + create.error = Some(err.clone()); + } + } Self::send_api_response( api.respond_to, encode_error(api.id, "worktree_create_failed", err), @@ -421,6 +427,17 @@ impl App { ws.set_custom_name(label); } } + if self + .state + .worktree_create + .as_ref() + .is_some_and(|create| create.checkout_path == result.path) + { + self.state.worktree_create = None; + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + self.state.mode = crate::app::Mode::Terminal; + } self.state.mark_session_dirty(); if created_workspace { self.emit_workspace_open_events(ws_idx); @@ -492,6 +509,17 @@ impl App { } else { "worktree_remove_failed" }; + if let Some(remove) = &mut self.state.worktree_remove { + if remove.workspace_id == result.workspace_id && remove.path == result.path { + remove.removing = false; + if code == "dirty_worktree_requires_force" && !remove.force_confirmation { + remove.force_confirmation = true; + remove.error = None; + } else { + remove.error = Some(message.clone()); + } + } + } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; } @@ -554,6 +582,16 @@ impl App { worktree, result.forced, ); + if self.state.worktree_remove.as_ref().is_some_and(|remove| { + remove.workspace_id == result.workspace_id && remove.path == result.path + }) { + self.state.worktree_remove = None; + self.state.mode = if self.state.active.is_some() { + crate::app::Mode::Terminal + } else { + crate::app::Mode::Navigate + }; + } let response = encode_success( api.id, ResponseResult::WorktreeRemoved { diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 5760c436..31a99c23 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -56,6 +56,7 @@ use self::{ modal::{ modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, }, + mouse::MouseAction, settings::SettingsAction, }; use super::state::{AppState, Mode}; @@ -86,19 +87,15 @@ impl App { Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event), Mode::Prefix | Mode::Navigate | Mode::Copy => unreachable!(), Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { - handle_rename_key(&mut self.state, key_event) + self.handle_rename_key_via_api(key_event) } Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event), Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event), Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event), - Mode::Resize => handle_resize_key(&mut self.state, key), - Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event), + Mode::Resize => self.handle_resize_key_via_api(key), + Mode::ConfirmClose => self.handle_confirm_close_key_via_api(key_event), Mode::ContextMenu => { - handle_context_menu_key( - &mut self.state, - &mut self.terminal_runtimes, - key_event, - ); + self.handle_context_menu_key_via_api(key_event); } Mode::Settings => self.handle_settings_key(key_event), Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event), @@ -259,24 +256,67 @@ impl App { let previous_agent_panel_sort = self.state.agent_panel_sort; let previous_settings_section = self.state.settings.section; if !handled_pane_double_click { + let right_button = matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Right) + | MouseEventKind::Up(MouseButton::Right) + | MouseEventKind::Drag(MouseButton::Right) + ); + let intentional_pane_press = matches!( + mouse.kind, + MouseEventKind::Down(MouseButton::Left | MouseButton::Middle) + ); + if !right_button + && intentional_pane_press + && matches!(self.state.mode, Mode::Terminal | Mode::Resize) + { + if let (Some(ws_idx), Some(info)) = ( + self.state.active, + self.state.pane_at(mouse.column, mouse.row).cloned(), + ) { + self.focus_pane_internal_via_api(ws_idx, info.id); + } + } if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) { match action { - SettingsAction::SaveTheme(name) => self.save_theme(&name), - SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToastDelivery(delivery) => { - self.save_toast_delivery(delivery) + MouseAction::Settings(action) => match action { + SettingsAction::SaveTheme(name) => self.save_theme(&name), + SettingsAction::SaveSound(enabled) => self.save_sound(enabled), + SettingsAction::SaveToastDelivery(delivery) => { + self.save_toast_delivery(delivery) + } + SettingsAction::SaveAgentBorderLabels(enabled) => { + self.save_agent_border_labels(enabled) + } + SettingsAction::SavePaneHistory(enabled) => { + self.save_pane_history_persistence(enabled) + } + SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => { + self.save_switch_ascii_input_source_in_prefix(enabled) + } + SettingsAction::InstallRecommendedIntegrations => { + self.install_recommended_integrations() + } + }, + MouseAction::FocusWorkspace { ws_idx } => { + self.focus_workspace_idx_via_api(ws_idx) } - SettingsAction::SaveAgentBorderLabels(enabled) => { - self.save_agent_border_labels(enabled) + MouseAction::FocusTab { tab_idx } => self.focus_tab_idx_via_api(tab_idx), + MouseAction::FocusPane { ws_idx, pane_id } => { + self.focus_pane_internal_via_api(ws_idx, pane_id) } - SettingsAction::SavePaneHistory(enabled) => { - self.save_pane_history_persistence(enabled) - } - SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => { - self.save_switch_ascii_input_source_in_prefix(enabled) - } - SettingsAction::InstallRecommendedIntegrations => { - self.install_recommended_integrations() + MouseAction::FocusToastTarget => self.focus_toast_target_via_api(), + MouseAction::MoveWorkspace { + source_ws_idx, + insert_idx, + } => self.move_workspace_via_api(source_ws_idx, insert_idx), + MouseAction::MoveTab { + ws_idx, + source_tab_idx, + insert_idx, + } => self.move_tab_via_api(ws_idx, source_tab_idx, insert_idx), + MouseAction::SetSplitRatio { path, ratio } => { + self.set_split_ratio_via_api(path, ratio) } } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index a8ffef7e..2c3f4e4a 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -2,8 +2,11 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use crate::{ - app::state::{ - AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, + app::{ + state::{ + AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode, NavigatorStateFilter, + }, + App, }, input::TerminalKey, layout::NavDirection, @@ -897,6 +900,367 @@ pub(crate) fn handle_context_menu_key( } } +impl App { + pub(crate) fn handle_rename_key_via_api(&mut self, key: KeyEvent) { + if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) { + match action { + ModalAction::Save => self.save_rename_modal_via_api(), + ModalAction::Clear => { + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + } + ModalAction::Cancel => cancel_rename_modal(&mut self.state), + _ => {} + } + return; + } + + handle_rename_key(&mut self.state, key); + } + + fn save_rename_modal_via_api(&mut self) { + let new_name = if self.state.name_input.trim().is_empty() { + self.state.name_input.clone() + } else { + self.state.name_input.trim().to_string() + }; + + match self.state.mode { + Mode::RenameWorkspace if !self.state.workspaces.is_empty() && !new_name.is_empty() => { + let workspace_id = self.public_workspace_id(self.state.selected); + self.dispatch_tui_api_request( + "tui.workspace.rename", + crate::api::schema::Method::WorkspaceRename( + crate::api::schema::WorkspaceRenameParams { + workspace_id, + label: new_name, + }, + ), + ); + } + Mode::RenameTab if self.state.creating_new_tab => { + let default_name = next_new_tab_default_name(&self.state); + let label = if new_name.is_empty() || new_name == default_name { + None + } else { + Some(new_name) + }; + self.dispatch_tui_api_request( + "tui.tab.create_named", + crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: None, + cwd: None, + focus: true, + label, + env: Default::default(), + }), + ); + } + Mode::RenameTab if !new_name.is_empty() => { + let Some(ws_idx) = self.state.active else { + cancel_rename_modal(&mut self.state); + return; + }; + let tab_idx = self.state.workspaces[ws_idx].active_tab; + let keep_auto_name = self.state.workspaces[ws_idx] + .tabs + .get(tab_idx) + .is_some_and(|tab| tab.is_auto_named()) + && self.state.workspaces[ws_idx] + .tab_display_name(tab_idx) + .is_some_and(|name| new_name == name); + if !keep_auto_name { + if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) { + self.dispatch_tui_api_request( + "tui.tab.rename", + crate::api::schema::Method::TabRename( + crate::api::schema::TabRenameParams { + tab_id, + label: new_name, + }, + ), + ); + } + } + } + Mode::RenamePane => { + if let (Some(ws_idx), Some(pane_id)) = + (self.state.active, self.state.rename_pane_target) + { + if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.dispatch_tui_api_request( + "tui.pane.rename", + crate::api::schema::Method::PaneRename( + crate::api::schema::PaneRenameParams { + pane_id, + label: Some(new_name), + }, + ), + ); + } + } + } + _ => {} + } + + cancel_rename_modal(&mut self.state); + } + + pub(crate) fn handle_resize_key_via_api(&mut self, raw_key: TerminalKey) { + let key = raw_key.as_key_event(); + if key.code == KeyCode::Esc + || key.code == KeyCode::Enter + || self.state.keybinds.resize_mode.matches_prefix_key(raw_key) + || self.state.keybinds.resize_mode.matches_direct_key(raw_key) + { + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + return; + } + + let direction = match key.code { + KeyCode::Char('h') | KeyCode::Left => Some(NavDirection::Left), + KeyCode::Char('l') | KeyCode::Right => Some(NavDirection::Right), + KeyCode::Char('j') | KeyCode::Down => Some(NavDirection::Down), + KeyCode::Char('k') | KeyCode::Up => Some(NavDirection::Up), + _ => None, + }; + if let Some(direction) = direction { + self.dispatch_tui_api_request( + "tui.pane.resize", + crate::api::schema::Method::PaneResize(crate::api::schema::PaneResizeParams { + pane_id: None, + direction: super::navigate::api_pane_direction(direction), + amount: None, + }), + ); + } + } + + pub(crate) fn handle_confirm_close_key_via_api(&mut self, key: KeyEvent) { + match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) { + Some(ModalAction::Confirm) => { + let ws_idx = self.state.selected; + if ws_idx < self.state.workspaces.len() { + self.close_workspace_idx_via_api(ws_idx); + } + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + Some(ModalAction::Cancel) => confirm_close_cancel(&mut self.state), + _ => {} + } + } + + pub(crate) fn handle_context_menu_key_via_api(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => { + self.state.context_menu = None; + leave_modal(&mut self.state); + } + KeyCode::Up => { + if let Some(menu) = &mut self.state.context_menu { + menu.list.move_prev(); + } + } + KeyCode::Down => { + if let Some(menu) = &mut self.state.context_menu { + menu.list.move_next(menu.items().len()); + } + } + KeyCode::Enter => { + if let Some(menu) = self.state.context_menu.take() { + let idx = menu.list.highlighted; + self.apply_context_menu_action_via_api(menu, idx); + } + } + _ => {} + } + } + + pub(crate) fn apply_context_menu_action_via_api(&mut self, menu: ContextMenuState, idx: usize) { + let item = menu.items().get(idx).copied(); + match (menu.kind, item) { + (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("New worktree")) => { + self.state.request_new_linked_worktree = Some(ws_idx); + leave_modal(&mut self.state); + } + (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Delete worktree checkout...")) => { + self.state.request_remove_linked_worktree = Some(ws_idx); + leave_modal(&mut self.state); + } + (ContextMenuKind::GitWorkspace { ws_idx, .. }, Some("Open worktree...")) => { + self.state.request_open_existing_worktree = Some(ws_idx); + leave_modal(&mut self.state); + } + ( + ContextMenuKind::GitWorkspace { + ws_idx, collapsed, .. + }, + Some("Collapse" | "Expand"), + ) => { + if let Some(key) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.worktree_space()) + .map(|space| space.key.clone()) + { + if collapsed { + self.state.collapsed_space_keys.remove(&key); + } else { + self.state.collapsed_space_keys.insert(key); + } + self.state.mark_session_dirty(); + } + leave_modal(&mut self.state); + } + ( + ContextMenuKind::Workspace { ws_idx } + | ContextMenuKind::GitWorkspace { ws_idx, .. }, + Some("Rename"), + ) => open_rename_workspace(&mut self.state, &self.terminal_runtimes, ws_idx), + ( + ContextMenuKind::Workspace { ws_idx } + | ContextMenuKind::GitWorkspace { ws_idx, .. }, + Some("Close" | "Close group"), + ) => { + self.state.selected = ws_idx; + if self.state.confirm_close { + open_confirm_close(&mut self.state); + } else { + self.close_workspace_idx_via_api(ws_idx); + self.state.mode = Mode::Navigate; + } + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => { + self.focus_workspace_idx_via_api(ws_idx); + self.focus_tab_idx_via_api(tab_idx); + open_new_tab_dialog(&mut self.state); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => { + self.focus_workspace_idx_via_api(ws_idx); + self.focus_tab_idx_via_api(tab_idx); + open_rename_active_tab(&mut self.state, false); + } + (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => { + self.focus_workspace_idx_via_api(ws_idx); + self.focus_tab_idx_via_api(tab_idx); + self.close_active_tab_via_api(); + } + (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { + open_rename_pane(&mut self.state, pane_id); + } + ( + ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some("Clear pane name"), + ) => { + if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.dispatch_tui_api_request( + "tui.pane.clear_name", + crate::api::schema::Method::PaneRename( + crate::api::schema::PaneRenameParams { + pane_id, + label: None, + }, + ), + ); + } + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { + ws_idx, + pane_id, + source_pane_id: Some(source_pane_id), + .. + }, + Some("Swap with focused pane"), + ) => { + let source_public_id = self.public_pane_id(ws_idx, source_pane_id); + let target_public_id = self.public_pane_id(ws_idx, pane_id); + if let (Some(source_public_id), Some(target_public_id)) = + (source_public_id, target_public_id) + { + self.dispatch_tui_api_request( + "tui.pane.swap_exact", + crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams { + pane_id: None, + direction: None, + source_pane_id: Some(source_public_id), + target_pane_id: Some(target_public_id), + }), + ); + self.focus_pane_internal_via_api(ws_idx, source_pane_id); + } + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some("Split right"), + ) => { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some("Split down"), + ) => { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some("Zoom"), + ) => { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.zoom_focused_pane_via_api(); + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some("Close pane"), + ) => { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.close_focused_pane_via_api(); + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; + } + _ => leave_modal(&mut self.state), + } + } +} + +fn cancel_rename_modal(state: &mut AppState) { + state.creating_new_tab = false; + state.requested_new_tab_name = None; + state.rename_pane_target = None; + state.name_input.clear(); + state.name_input_replace_on_type = false; + leave_modal(state); +} + impl AppState { pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option { let rect = self.global_menu_rect(); diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index c8a03674..71c0cfbd 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -26,6 +26,40 @@ use super::{ ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD, }; +pub(super) enum MouseAction { + Settings(SettingsAction), + FocusWorkspace { + ws_idx: usize, + }, + FocusTab { + tab_idx: usize, + }, + FocusPane { + ws_idx: usize, + pane_id: crate::layout::PaneId, + }, + FocusToastTarget, + MoveWorkspace { + source_ws_idx: usize, + insert_idx: usize, + }, + MoveTab { + ws_idx: usize, + source_tab_idx: usize, + insert_idx: usize, + }, + SetSplitRatio { + path: Vec, + ratio: f32, + }, +} + +enum MobileMouseResult { + Ignored, + Consumed, + Action(MouseAction), +} + impl AppState { pub(crate) fn handle_pane_mouse_only( &mut self, @@ -59,7 +93,7 @@ impl AppState { &mut self, terminal_runtimes: &mut TerminalRuntimeRegistry, mouse: MouseEvent, - ) -> Option { + ) -> Option { if self.mode == Mode::Onboarding { self.handle_onboarding_mouse(mouse); return None; @@ -69,8 +103,7 @@ impl AppState { && self.clickable_toast_at(mouse.column, mouse.row) && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - self.focus_toast_target(); - return None; + return Some(MouseAction::FocusToastTarget); } if self.mode == Mode::Terminal @@ -81,7 +114,7 @@ impl AppState { } if self.mode == Mode::Settings { - return self.handle_settings_mouse(mouse); + return self.handle_settings_mouse(mouse).map(MouseAction::Settings); } let launcher_enabled = self.view.layout != ViewLayout::Mobile @@ -134,8 +167,12 @@ impl AppState { return None; } - if self.view.layout == ViewLayout::Mobile && self.handle_mobile_mouse(mouse) { - return None; + if self.view.layout == ViewLayout::Mobile { + match self.handle_mobile_mouse(mouse) { + MobileMouseResult::Ignored => {} + MobileMouseResult::Consumed => return None, + MobileMouseResult::Action(action) => return Some(action), + } } let sidebar = self.view.sidebar_rect; @@ -473,16 +510,15 @@ impl AppState { if self.sidebar_collapsed { if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) { - self.switch_workspace(idx); self.mode = Mode::Terminal; - return None; + return Some(MouseAction::FocusWorkspace { ws_idx: idx }); } if let Some((ws_idx, _tab_idx, pane_id)) = self.collapsed_agent_detail_target_at(mouse.row) { - self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; + return Some(MouseAction::FocusPane { ws_idx, pane_id }); } return None; } @@ -574,12 +610,10 @@ impl AppState { if let Some((ws_idx, _tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) { - self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; - return None; + return Some(MouseAction::FocusPane { ws_idx, pane_id }); } } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() { - self.focus_pane(info.id); if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } @@ -587,6 +621,12 @@ impl AppState { if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) { self.selection = None; self.selection_autoscroll = None; + if let Some(ws_idx) = self.active { + return Some(MouseAction::FocusPane { + ws_idx, + pane_id: info.id, + }); + } return None; } @@ -600,6 +640,12 @@ impl AppState { col, self.pane_scroll_metrics(terminal_runtimes, info.id), )); + if let Some(ws_idx) = self.active { + return Some(MouseAction::FocusPane { + ws_idx, + pane_id: info.id, + }); + } } else if let Some(info) = self.view.pane_infos.iter().find(|p| { mouse.column >= p.rect.x && mouse.column < p.rect.x + p.rect.width @@ -607,10 +653,15 @@ impl AppState { && mouse.row < p.rect.y + p.rect.height }) { let id = info.id; - self.focus_pane(id); if self.mode != Mode::Terminal { self.mode = Mode::Terminal; } + if let Some(ws_idx) = self.active { + return Some(MouseAction::FocusPane { + ws_idx, + pane_id: id, + }); + } } } @@ -721,10 +772,7 @@ impl AppState { }; let ratio = ratio.clamp(0.1, 0.9); let path = path.clone(); - if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) { - ws.layout.set_ratio_at(&path, ratio); - self.mark_session_dirty(); - } + return Some(MouseAction::SetSplitRatio { path, ratio }); } DragTarget::PaneScrollbar { pane_id, @@ -798,7 +846,10 @@ impl AppState { insert_idx: Some(insert_idx), }, }) => { - self.move_workspace(source_ws_idx, insert_idx); + return Some(MouseAction::MoveWorkspace { + source_ws_idx, + insert_idx, + }); } Some(DragState { target: @@ -809,22 +860,28 @@ impl AppState { }, }) => { if self.active == Some(ws_idx) { - self.move_tab(source_tab_idx, insert_idx); self.mode = Mode::Terminal; + return Some(MouseAction::MoveTab { + ws_idx, + source_tab_idx, + insert_idx, + }); } } Some(_) => {} None => { if let Some(press) = workspace_press { - self.switch_workspace(press.ws_idx); self.mode = Mode::Terminal; - return None; + return Some(MouseAction::FocusWorkspace { + ws_idx: press.ws_idx, + }); } if let Some(press) = tab_press { if self.active == Some(press.ws_idx) { - self.switch_tab(press.tab_idx); self.mode = Mode::Terminal; - return None; + return Some(MouseAction::FocusTab { + tab_idx: press.tab_idx, + }); } } } @@ -1021,40 +1078,40 @@ impl AppState { None } - fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> bool { + fn handle_mobile_mouse(&mut self, mouse: MouseEvent) -> MobileMouseResult { if self.mode == Mode::Navigate { match mouse.kind { MouseEventKind::ScrollUp => { self.scroll_mobile_switcher_at(mouse.column, mouse.row, -1); - return true; + return MobileMouseResult::Consumed; } MouseEventKind::ScrollDown => { self.scroll_mobile_switcher_at(mouse.column, mouse.row, 1); - return true; + return MobileMouseResult::Consumed; } MouseEventKind::Down(MouseButton::Left) => {} - _ => return true, + _ => return MobileMouseResult::Consumed, } } else if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - return false; + return MobileMouseResult::Ignored; } if self.mode != Mode::Navigate { if !matches!(self.mode, Mode::Terminal | Mode::Resize) { - return false; + return MobileMouseResult::Ignored; } if rect_contains(self.view.mobile_menu_hit_area, mouse.column, mouse.row) { self.mobile_switcher_scroll = 0; self.mode = Mode::Navigate; - return true; + return MobileMouseResult::Consumed; } - return false; + return MobileMouseResult::Ignored; } let areas = crate::ui::mobile_switcher_areas(self); if rect_contains(areas.close, mouse.column, mouse.row) { self.mode = Mode::Terminal; - return true; + return MobileMouseResult::Consumed; } match crate::ui::mobile_switcher_target_at(self, mouse.column, mouse.row) { @@ -1062,8 +1119,8 @@ impl AppState { self.request_new_workspace = true; } Some(crate::ui::MobileSwitcherTarget::Workspace(ws_idx)) => { - self.switch_workspace(ws_idx); self.mode = Mode::Terminal; + return MobileMouseResult::Action(MouseAction::FocusWorkspace { ws_idx }); } Some(crate::ui::MobileSwitcherTarget::NewTab) => { if self.prompt_new_tab_name { @@ -1074,16 +1131,16 @@ impl AppState { } } Some(crate::ui::MobileSwitcherTarget::Tab(tab_idx)) => { - self.switch_tab(tab_idx); self.mode = Mode::Terminal; + return MobileMouseResult::Action(MouseAction::FocusTab { tab_idx }); } Some(crate::ui::MobileSwitcherTarget::Agent { ws_idx, tab_idx: _, pane_id, }) => { - self.focus_pane_in_workspace(ws_idx, pane_id); self.mode = Mode::Terminal; + return MobileMouseResult::Action(MouseAction::FocusPane { ws_idx, pane_id }); } Some(crate::ui::MobileSwitcherTarget::Menu(action_idx)) => { let actions = global_menu_actions(self); @@ -1094,7 +1151,7 @@ impl AppState { None => {} } - true + MobileMouseResult::Consumed } fn scroll_mobile_switcher_at(&mut self, _col: u16, _row: u16, delta: i16) { @@ -1335,9 +1392,7 @@ impl AppState { } pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) { - if let Some(ws_idx) = self.active { - self.focus_pane_in_workspace(ws_idx, pane_id); - } + let _ = pane_id; } fn clickable_toast_at(&self, col: u16, row: u16) -> bool { diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 7072f659..c6ad3e16 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -56,12 +56,7 @@ impl App { self.launch_focused_scrollback_editor(); finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode); } else { - execute_navigate_action_in_context( - &mut self.state, - &mut self.terminal_runtimes, - action, - ActionContext::Prefix, - ); + self.execute_tui_navigate_action(action, ActionContext::Prefix); } self.selection_autoscroll_deadline = None; return; @@ -84,7 +79,8 @@ impl App { return; } - if handle_navigate_reserved_key(&mut self.state, raw_key) { + if let Some(action) = navigate_reserved_action_for_key(&self.state, raw_key) { + self.execute_tui_navigate_action(action, ActionContext::Navigate); return; } @@ -92,12 +88,7 @@ impl App { if action == NavigateAction::EditScrollback { self.launch_focused_scrollback_editor(); } else { - execute_navigate_action_in_context( - &mut self.state, - &mut self.terminal_runtimes, - action, - ActionContext::Navigate, - ); + self.execute_tui_navigate_action(action, ActionContext::Navigate); } self.selection_autoscroll_deadline = None; return; @@ -108,6 +99,595 @@ impl App { } } + pub(super) fn execute_tui_navigate_action( + &mut self, + action: NavigateAction, + context: ActionContext, + ) { + let previous_mode = self.state.mode; + match action { + NavigateAction::NewWorkspace => { + self.dispatch_tui_api_request( + "tui.key.workspace.create", + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + ); + leave_navigate_mode(&mut self.state); + } + NavigateAction::NewWorktree => { + if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { + workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) + }) { + self.state.request_new_linked_worktree = Some(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::OpenWorktree => { + if let Some(ws_idx) = workspace_action_target(&self.state, context).filter(|idx| { + workspace_can_start_worktree_action(&self.state, &self.terminal_runtimes, *idx) + }) { + self.state.request_open_existing_worktree = Some(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::RemoveWorktree => { + if let Some(ws_idx) = workspace_action_target(&self.state, context) { + self.state.request_remove_linked_worktree = Some(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::RenameWorkspace => { + if let Some(ws_idx) = workspace_action_target(&self.state, context) { + super::modal::open_rename_workspace( + &mut self.state, + &self.terminal_runtimes, + ws_idx, + ); + } + } + NavigateAction::CloseWorkspace => { + if let Some(ws_idx) = workspace_action_target(&self.state, context) { + self.state.selected = ws_idx; + if self.state.confirm_close { + super::modal::open_confirm_close(&mut self.state); + } else { + self.close_workspace_idx_via_api(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + } + NavigateAction::SwitchWorkspace(idx) => { + if let Some(ws_idx) = self.state.workspace_at_visible_position(idx) { + self.focus_workspace_idx_via_api(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::SwitchTab(idx) => { + if self + .state + .active + .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) + .is_some_and(|ws| idx < ws.tabs.len()) + { + self.focus_tab_idx_via_api(idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::FocusAgent(idx) => { + if let Some((ws_idx, pane_id)) = self.agent_entry_target(idx) { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.state.ensure_agent_panel_entry_visible(idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::WorkspacePicker => { + self.state.mobile_switcher_scroll = 0; + self.state.mode = Mode::Navigate; + } + NavigateAction::PreviousWorkspace => { + if let Some(ws_idx) = self.relative_visible_workspace(-1) { + self.focus_workspace_idx_via_api(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::NextWorkspace => { + if let Some(ws_idx) = self.relative_visible_workspace(1) { + self.focus_workspace_idx_via_api(ws_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::PreviousAgent => { + if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(false) { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.state.ensure_agent_panel_entry_visible(idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::NextAgent => { + if let Some((idx, ws_idx, pane_id)) = self.relative_agent_entry(true) { + self.focus_pane_internal_via_api(ws_idx, pane_id); + self.state.ensure_agent_panel_entry_visible(idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::NewTab => { + if self.state.active.is_some() { + if self.state.prompt_new_tab_name { + super::modal::open_new_tab_dialog(&mut self.state); + } else { + self.dispatch_tui_api_request( + "tui.key.tab.create", + crate::api::schema::Method::TabCreate( + crate::api::schema::TabCreateParams { + workspace_id: None, + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + ); + leave_navigate_mode(&mut self.state); + } + } + } + NavigateAction::RenameTab => { + super::modal::open_rename_active_tab(&mut self.state, false) + } + NavigateAction::PreviousTab => { + if let Some(tab_idx) = self.relative_tab(-1) { + self.focus_tab_idx_via_api(tab_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::NextTab => { + if let Some(tab_idx) = self.relative_tab(1) { + self.focus_tab_idx_via_api(tab_idx); + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::CloseTab => { + self.close_active_tab_via_api(); + leave_navigate_mode(&mut self.state); + } + NavigateAction::RenamePane => { + if let Some(pane_id) = self + .state + .active + .and_then(|ws_idx| self.state.workspaces.get(ws_idx)) + .and_then(|ws| ws.focused_pane_id()) + { + super::modal::open_rename_pane(&mut self.state, pane_id); + } + } + NavigateAction::FocusPaneLeft => self.focus_pane_direction_via_api(NavDirection::Left), + NavigateAction::FocusPaneDown => self.focus_pane_direction_via_api(NavDirection::Down), + NavigateAction::FocusPaneUp => self.focus_pane_direction_via_api(NavDirection::Up), + NavigateAction::FocusPaneRight => { + self.focus_pane_direction_via_api(NavDirection::Right) + } + NavigateAction::SwapPaneLeft => { + self.swap_pane_direction_via_api(NavDirection::Left); + leave_navigate_mode(&mut self.state); + } + NavigateAction::SwapPaneDown => { + self.swap_pane_direction_via_api(NavDirection::Down); + leave_navigate_mode(&mut self.state); + } + NavigateAction::SwapPaneUp => { + self.swap_pane_direction_via_api(NavDirection::Up); + leave_navigate_mode(&mut self.state); + } + NavigateAction::SwapPaneRight => { + self.swap_pane_direction_via_api(NavDirection::Right); + leave_navigate_mode(&mut self.state); + } + NavigateAction::SplitVertical => { + self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Right); + leave_navigate_mode(&mut self.state); + } + NavigateAction::SplitHorizontal => { + self.split_focused_pane_via_api(crate::api::schema::SplitDirection::Down); + leave_navigate_mode(&mut self.state); + } + NavigateAction::ClosePane => { + self.close_focused_pane_via_api(); + leave_navigate_mode(&mut self.state); + } + NavigateAction::EditScrollback => {} + NavigateAction::CopyMode => self.state.enter_copy_mode(&self.terminal_runtimes), + NavigateAction::Zoom => { + self.zoom_focused_pane_via_api(); + leave_navigate_mode(&mut self.state); + } + NavigateAction::EnterResizeMode => self.state.mode = Mode::Resize, + NavigateAction::ToggleSidebar => { + self.state.sidebar_collapsed = !self.state.sidebar_collapsed; + leave_navigate_mode(&mut self.state); + } + NavigateAction::CyclePaneNext => { + self.cycle_pane_via_api(false); + leave_navigate_mode(&mut self.state); + } + NavigateAction::CyclePanePrevious => { + self.cycle_pane_via_api(true); + leave_navigate_mode(&mut self.state); + } + NavigateAction::LastPane => { + self.last_pane_via_api(); + leave_navigate_mode(&mut self.state); + } + NavigateAction::Help => super::modal::open_keybind_help(&mut self.state), + NavigateAction::Settings => super::settings::open_settings(&mut self.state), + NavigateAction::ReloadConfig => { + self.dispatch_tui_api_request( + "tui.server.reload_config", + crate::api::schema::Method::ServerReloadConfig( + crate::api::schema::EmptyParams::default(), + ), + ); + leave_navigate_mode(&mut self.state); + } + NavigateAction::OpenNotificationTarget => { + self.focus_toast_target_via_api(); + if self.state.mode == Mode::Navigate { + leave_navigate_mode(&mut self.state); + } + } + NavigateAction::Detach => { + super::modal::request_detach(&mut self.state); + leave_navigate_mode(&mut self.state); + } + NavigateAction::OpenNavigator => { + self.state.open_navigator_from(&self.terminal_runtimes) + } + } + + finish_action_context(&mut self.state, context, previous_mode); + } + + pub(crate) fn focus_workspace_idx_via_api(&mut self, ws_idx: usize) { + let workspace_id = self.public_workspace_id(ws_idx); + self.dispatch_tui_api_request( + "tui.workspace.focus", + crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { + workspace_id, + }), + ); + } + + pub(crate) fn close_workspace_idx_via_api(&mut self, ws_idx: usize) { + let workspace_id = self.public_workspace_id(ws_idx); + self.dispatch_tui_api_request( + "tui.workspace.close", + crate::api::schema::Method::WorkspaceClose(crate::api::schema::WorkspaceTarget { + workspace_id, + }), + ); + } + + pub(crate) fn move_workspace_via_api(&mut self, source_ws_idx: usize, insert_idx: usize) { + let workspace_id = self.public_workspace_id(source_ws_idx); + self.dispatch_tui_api_request( + "tui.workspace.move", + crate::api::schema::Method::WorkspaceMove(crate::api::schema::WorkspaceMoveParams { + workspace_id, + insert_index: insert_idx, + }), + ); + } + + pub(crate) fn focus_tab_idx_via_api(&mut self, tab_idx: usize) { + let Some(ws_idx) = self.state.active else { + return; + }; + let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { + return; + }; + self.dispatch_tui_api_request( + "tui.tab.focus", + crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { tab_id }), + ); + } + + pub(crate) fn close_active_tab_via_api(&mut self) { + let Some(ws_idx) = self.state.active else { + return; + }; + let tab_idx = self.state.workspaces[ws_idx].active_tab_index(); + let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) else { + return; + }; + self.dispatch_tui_api_request( + "tui.tab.close", + crate::api::schema::Method::TabClose(crate::api::schema::TabTarget { tab_id }), + ); + } + + pub(crate) fn move_tab_via_api( + &mut self, + ws_idx: usize, + source_tab_idx: usize, + insert_idx: usize, + ) { + let Some(tab_id) = self.public_tab_id(ws_idx, source_tab_idx) else { + return; + }; + self.dispatch_tui_api_request( + "tui.tab.move", + crate::api::schema::Method::TabMove(crate::api::schema::TabMoveParams { + tab_id, + insert_index: insert_idx, + }), + ); + } + + pub(crate) fn focus_pane_internal_via_api( + &mut self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + ) { + let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { + return; + }; + self.dispatch_tui_api_request( + "tui.pane.focus", + crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }), + ); + } + + pub(crate) fn focus_pane_direction_via_api(&mut self, direction: NavDirection) { + if let Some((ws_idx, target)) = self.directional_pane_target_from_view(direction) { + self.focus_pane_internal_via_api(ws_idx, target); + return; + } + self.dispatch_tui_api_request( + "tui.pane.focus_direction", + crate::api::schema::Method::PaneFocusDirection( + crate::api::schema::PaneFocusDirectionParams { + pane_id: None, + direction: api_pane_direction(direction), + }, + ), + ); + } + + pub(crate) fn swap_pane_direction_via_api(&mut self, direction: NavDirection) { + if let Some((ws_idx, source, target)) = self.directional_pane_swap_from_view(direction) { + let source_pane_id = self.public_pane_id(ws_idx, source); + let target_pane_id = self.public_pane_id(ws_idx, target); + if let (Some(source_pane_id), Some(target_pane_id)) = (source_pane_id, target_pane_id) { + self.dispatch_tui_api_request( + "tui.pane.swap_exact", + crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams { + pane_id: None, + direction: None, + source_pane_id: Some(source_pane_id), + target_pane_id: Some(target_pane_id), + }), + ); + return; + } + } + self.dispatch_tui_api_request( + "tui.pane.swap", + crate::api::schema::Method::PaneSwap(crate::api::schema::PaneSwapParams { + pane_id: None, + direction: Some(api_pane_direction(direction)), + source_pane_id: None, + target_pane_id: None, + }), + ); + } + + pub(crate) fn split_focused_pane_via_api( + &mut self, + direction: crate::api::schema::SplitDirection, + ) { + self.dispatch_tui_api_request( + "tui.pane.split", + crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams { + workspace_id: None, + target_pane_id: None, + direction, + ratio: None, + cwd: None, + focus: true, + env: Default::default(), + }), + ); + } + + pub(crate) fn close_focused_pane_via_api(&mut self) { + let Some((ws_idx, pane_id)) = self.focused_pane_target() else { + return; + }; + let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) else { + return; + }; + self.dispatch_tui_api_request( + "tui.pane.close", + crate::api::schema::Method::PaneClose(crate::api::schema::PaneTarget { pane_id }), + ); + } + + pub(crate) fn zoom_focused_pane_via_api(&mut self) { + self.dispatch_tui_api_request( + "tui.pane.zoom", + crate::api::schema::Method::PaneZoom(crate::api::schema::PaneZoomParams { + pane_id: None, + mode: crate::api::schema::PaneZoomMode::Toggle, + }), + ); + } + + pub(crate) fn set_split_ratio_via_api(&mut self, path: Vec, ratio: f32) { + self.dispatch_tui_api_request( + "tui.layout.set_split_ratio", + crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: None, + pane_id: None, + path, + ratio, + }, + ), + ); + } + + pub(crate) fn cycle_pane_via_api(&mut self, reverse: bool) { + let Some((ws_idx, pane_id)) = self.focused_pane_target() else { + return; + }; + let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { + return; + }; + let ids = tab.layout.pane_ids(); + let Some(pos) = ids.iter().position(|id| *id == pane_id) else { + return; + }; + let target = if reverse { + ids[(pos + ids.len() - 1) % ids.len()] + } else { + ids[(pos + 1) % ids.len()] + }; + self.focus_pane_internal_via_api(ws_idx, target); + } + + pub(crate) fn last_pane_via_api(&mut self) { + let Some(target) = self.state.previous_pane_focus.clone() else { + return; + }; + let Some((ws_idx, _tab_idx)) = self.state.pane_focus_target_indices(&target) else { + self.state.previous_pane_focus = None; + return; + }; + if self.state.current_pane_focus_target().as_ref() == Some(&target) { + self.state.previous_pane_focus = None; + return; + } + self.focus_pane_internal_via_api(ws_idx, target.pane_id); + } + + pub(crate) fn focus_toast_target_via_api(&mut self) { + let Some(target) = self + .state + .toast + .as_ref() + .and_then(|toast| toast.target.clone()) + else { + return; + }; + let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|workspace| workspace.id == target.workspace_id) + else { + return; + }; + self.focus_pane_internal_via_api(ws_idx, target.pane_id); + self.state.toast = None; + self.state.mode = Mode::Terminal; + } + + fn focused_pane_target(&self) -> Option<(usize, crate::layout::PaneId)> { + let ws_idx = self.state.active?; + let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; + Some((ws_idx, pane_id)) + } + + fn directional_pane_target_from_view( + &self, + direction: NavDirection, + ) -> Option<(usize, crate::layout::PaneId)> { + let ws_idx = self.state.active?; + let focused = self + .state + .view + .pane_infos + .iter() + .find(|pane| pane.is_focused)?; + let target = + crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; + Some((ws_idx, target)) + } + + fn directional_pane_swap_from_view( + &self, + direction: NavDirection, + ) -> Option<(usize, crate::layout::PaneId, crate::layout::PaneId)> { + let ws_idx = self.state.active?; + let focused = self + .state + .view + .pane_infos + .iter() + .find(|pane| pane.is_focused)?; + let target = + crate::layout::find_in_direction(focused, direction, &self.state.view.pane_infos)?; + Some((ws_idx, focused.id, target)) + } + + fn relative_visible_workspace(&self, delta: isize) -> Option { + let order = self.state.visible_workspace_order(); + if order.is_empty() { + return None; + } + let current = self.state.active.unwrap_or(self.state.selected); + let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0); + let next = (current_pos as isize + delta).rem_euclid(order.len() as isize) as usize; + order.get(next).copied() + } + + fn relative_tab(&self, delta: isize) -> Option { + let ws = self + .state + .active + .and_then(|ws_idx| self.state.workspaces.get(ws_idx))?; + if ws.tabs.is_empty() { + return None; + } + Some((ws.active_tab as isize + delta).rem_euclid(ws.tabs.len() as isize) as usize) + } + + fn agent_entry_target(&self, idx: usize) -> Option<(usize, crate::layout::PaneId)> { + let entries = crate::ui::agent_panel_entries(&self.state); + let target = entries.get(idx)?; + Some((target.ws_idx, target.pane_id)) + } + + fn relative_agent_entry(&self, forward: bool) -> Option<(usize, usize, crate::layout::PaneId)> { + let entries = crate::ui::agent_panel_entries(&self.state); + if entries.is_empty() { + return None; + } + let focused = self + .state + .active + .and_then(|idx| self.state.workspaces.get(idx)) + .and_then(crate::workspace::Workspace::focused_pane_id); + let current_idx = entries + .iter() + .position(|entry| Some(entry.pane_id) == focused) + .unwrap_or(0); + let next_idx = if forward { + (current_idx + 1) % entries.len() + } else if current_idx == 0 { + entries.len() - 1 + } else { + current_idx - 1 + }; + let target = entries.get(next_idx)?; + Some((next_idx, target.ws_idx, target.pane_id)) + } + fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool { let Some(ws_idx) = self.state.active else { return false; @@ -540,6 +1120,66 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKe false } +fn navigate_reserved_action_for_key(state: &AppState, key: TerminalKey) -> Option { + let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers)); + if modifiers.is_empty() { + match code { + KeyCode::Enter => { + return (!state.workspaces.is_empty()).then_some(NavigateAction::SwitchWorkspace( + state + .visible_workspace_order() + .iter() + .position(|idx| *idx == state.selected) + .unwrap_or(state.selected), + )); + } + KeyCode::Char(c @ '1'..='9') => { + return Some(NavigateAction::SwitchWorkspace( + (c as usize) - ('1' as usize), + )); + } + KeyCode::Tab => return Some(NavigateAction::CyclePaneNext), + KeyCode::BackTab => return Some(NavigateAction::CyclePanePrevious), + KeyCode::Left => return Some(NavigateAction::FocusPaneLeft), + KeyCode::Right => return Some(NavigateAction::FocusPaneRight), + _ => {} + } + } + + if state.keybinds.navigate.workspace_up.matches_direct_key(key) + || state + .keybinds + .navigate + .workspace_down + .matches_direct_key(key) + { + return None; + } + if state.keybinds.navigate.pane_left.matches_direct_key(key) { + return Some(NavigateAction::FocusPaneLeft); + } + if state.keybinds.navigate.pane_down.matches_direct_key(key) { + return Some(NavigateAction::FocusPaneDown); + } + if state.keybinds.navigate.pane_up.matches_direct_key(key) { + return Some(NavigateAction::FocusPaneUp); + } + if state.keybinds.navigate.pane_right.matches_direct_key(key) { + return Some(NavigateAction::FocusPaneRight); + } + + None +} + +pub(super) fn api_pane_direction(direction: NavDirection) -> crate::api::schema::PaneDirection { + match direction { + NavDirection::Left => crate::api::schema::PaneDirection::Left, + NavDirection::Right => crate::api::schema::PaneDirection::Right, + NavDirection::Up => crate::api::schema::PaneDirection::Up, + NavDirection::Down => crate::api::schema::PaneDirection::Down, + } +} + #[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) { let mut terminal_runtimes = TerminalRuntimeRegistry::new(); @@ -1698,7 +2338,7 @@ last_pane = "prefix+tab" app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT)); - assert!(app.state.request_new_workspace); + assert_eq!(app.state.workspaces.len(), 2); assert_eq!(app.state.mode, Mode::Terminal); } @@ -1719,7 +2359,7 @@ last_pane = "prefix+tab" app.handle_navigate_key(TerminalKey::new(KeyCode::Char('N'), KeyModifiers::empty())); - assert!(app.state.request_new_workspace); + assert_eq!(app.state.workspaces.len(), 2); assert_eq!(app.state.mode, Mode::Terminal); } @@ -1760,7 +2400,7 @@ last_pane = "prefix+tab" app.handle_navigate_key(TerminalKey::new(KeyCode::Char('R'), KeyModifiers::empty())); - assert!(app.state.request_reload_config); + assert!(!app.state.request_reload_config); assert_eq!(app.state.mode, Mode::Terminal); } diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index e13412a2..b7a23322 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -45,12 +45,7 @@ impl App { if action == super::navigate::NavigateAction::EditScrollback { self.launch_focused_scrollback_editor(); } else { - super::navigate::execute_navigate_action_in_context( - &mut self.state, - &mut self.terminal_runtimes, - action, - super::navigate::ActionContext::Direct, - ); + self.execute_tui_navigate_action(action, super::navigate::ActionContext::Direct); } return None; } diff --git a/src/app/mod.rs b/src/app/mod.rs index bf4fecca..65863fd8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -884,13 +884,33 @@ impl App { if self.state.request_new_workspace { self.state.request_new_workspace = false; - self.create_workspace(); + self.dispatch_tui_api_request( + "tui.workspace.create", + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + cwd: None, + focus: true, + label: None, + env: Default::default(), + }, + ), + ); needs_render = true; } if self.state.request_new_tab { self.state.request_new_tab = false; - self.create_tab(); + let label = self.state.requested_new_tab_name.take(); + self.dispatch_tui_api_request( + "tui.tab.create", + crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: None, + cwd: None, + focus: true, + label, + env: Default::default(), + }), + ); needs_render = true; } @@ -905,10 +925,17 @@ impl App { } if let Some(cwd) = self.state.request_new_workspace_cwd.take() { - if let Err(err) = self.create_workspace_with_events(cwd, true) { - tracing::error!(err = %err, "failed to create workspace at requested cwd"); - self.state.mode = Mode::Navigate; - } + self.dispatch_tui_api_request( + "tui.workspace.create_cwd", + crate::api::schema::Method::WorkspaceCreate( + crate::api::schema::WorkspaceCreateParams { + cwd: Some(cwd.display().to_string()), + focus: true, + label: None, + env: Default::default(), + }, + ), + ); needs_render = true; } @@ -919,19 +946,19 @@ impl App { if self.state.request_submit_worktree_create { self.state.request_submit_worktree_create = false; - self.start_worktree_add(); + self.submit_worktree_create_via_api(); needs_render = true; } if self.state.request_submit_worktree_open { self.state.request_submit_worktree_open = false; - self.open_selected_existing_worktree(); + self.submit_worktree_open_via_api(); needs_render = true; } if self.state.request_submit_worktree_remove { self.state.request_submit_worktree_remove = false; - self.start_worktree_remove(); + self.submit_worktree_remove_via_api(); needs_render = true; } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 12e7ae84..d33e7f3c 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -559,6 +559,52 @@ impl App { }); } + pub(crate) fn submit_worktree_create_via_api(&mut self) { + self.sync_worktree_branch_from_input(); + let Some(create) = &mut self.state.worktree_create else { + return; + }; + let branch = create.branch.trim().to_string(); + if branch.is_empty() { + create.error = Some("branch is required".into()); + return; + } + if create.creating { + return; + } + + create.branch = branch.clone(); + self.state.name_input = branch.clone(); + create.checkout_path = crate::worktree::default_checkout_path( + &self.state.worktree_directory, + &create.repo_name, + &branch, + ); + create.creating = true; + create.error = None; + let workspace_id = create.source_workspace_id.clone(); + let checkout_path = create.checkout_path.display().to_string(); + + let immediate_response = self.dispatch_tui_deferred_api_request( + "tui.worktree.create", + crate::api::schema::Method::WorktreeCreate(crate::api::schema::WorktreeCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + branch: Some(branch), + path: Some(checkout_path), + base: Some("HEAD".into()), + focus: true, + label: None, + }), + ); + if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { + if let Some(create) = &mut self.state.worktree_create { + create.creating = false; + create.error = Some(message); + } + } + } + pub(crate) fn handle_worktree_remove_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc => { @@ -656,6 +702,76 @@ impl App { }); } + pub(crate) fn submit_worktree_open_via_api(&mut self) { + let Some(open) = self.state.worktree_open.as_ref() else { + return; + }; + let Some(entry_idx) = open.selected_entry_index() else { + return; + }; + let Some(entry) = open.entries.get(entry_idx).cloned() else { + return; + }; + let source_workspace_id = open.source_workspace_id.clone(); + + let response = self.dispatch_tui_api_request( + "tui.worktree.open", + crate::api::schema::Method::WorktreeOpen(crate::api::schema::WorktreeOpenParams { + workspace_id: Some(source_workspace_id), + cwd: None, + path: Some(entry.path.display().to_string()), + branch: None, + focus: true, + label: None, + }), + ); + if serde_json::from_str::(&response).is_ok() { + self.state.worktree_open = None; + self.state.mode = Mode::Terminal; + } else if let Ok(error) = + serde_json::from_str::(&response) + { + if let Some(open) = &mut self.state.worktree_open { + open.error = Some(error.error.message); + } + } + } + + pub(crate) fn submit_worktree_remove_via_api(&mut self) { + let Some(remove) = self.state.worktree_remove.as_mut() else { + return; + }; + if remove.removing { + return; + } + #[cfg(windows)] + if !remove.force_confirmation + && crate::worktree::checkout_has_dirty_files(&remove.path).unwrap_or(false) + { + remove.force_confirmation = true; + remove.error = None; + return; + } + + remove.removing = true; + remove.error = None; + let workspace_id = remove.workspace_id.clone(); + let force = remove.force_confirmation; + let immediate_response = self.dispatch_tui_deferred_api_request( + "tui.worktree.remove", + crate::api::schema::Method::WorktreeRemove(crate::api::schema::WorktreeRemoveParams { + workspace_id, + force, + }), + ); + if let Some(message) = immediate_api_error_message(immediate_response.as_deref()) { + if let Some(remove) = &mut self.state.worktree_remove { + remove.removing = false; + remove.error = Some(message); + } + } + } + pub(crate) fn handle_worktree_add_finished(&mut self, result: WorktreeAddResult) { if result.api_request.is_some() { self.handle_api_worktree_add_finished(result); @@ -864,6 +980,14 @@ impl App { } } +fn immediate_api_error_message(response: Option<&str>) -> Option { + response + .and_then(|response| { + serde_json::from_str::(response).ok() + }) + .map(|response| response.error.message) +} + #[cfg(test)] mod tests { use super::*; @@ -1394,6 +1518,76 @@ mod tests { assert_eq!(create.error, None); } + #[test] + fn submit_worktree_create_via_api_restores_modal_on_immediate_rejection() { + let mut app = app_for_worktree_tests(); + app.state.worktree_directory = std::path::PathBuf::from("/w"); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("source")]; + let source_workspace_id = app.state.workspaces[0].id.clone(); + let source_membership = crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }; + let branch = "issue/191"; + let checkout_path = + crate::worktree::default_checkout_path(&app.state.worktree_directory, "herdr", branch); + let checkout_key = crate::worktree::canonical_or_original(&checkout_path); + app.pending_api_worktree_creates.insert(checkout_key, 1); + app.state.workspaces[0].worktree_space = Some(source_membership.clone()); + app.state.name_input = branch.into(); + app.state.worktree_create = Some(WorktreeCreateState { + source_workspace_id, + source_checkout_path: "/repo/herdr".into(), + source_existing_membership: Some(source_membership), + source_repo_root: "/repo/herdr".into(), + repo_key: "repo-key".into(), + repo_name: "herdr".into(), + branch: branch.into(), + checkout_path, + error: None, + creating: false, + }); + + app.submit_worktree_create_via_api(); + + let create = app.state.worktree_create.as_ref().unwrap(); + assert!(!create.creating); + assert_eq!( + create.error.as_deref(), + Some("worktree operation is already in progress for this checkout") + ); + } + + #[test] + fn submit_worktree_remove_via_api_restores_modal_on_immediate_rejection() { + let mut app = app_for_worktree_tests(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; + let workspace_id = app.state.workspaces[0].id.clone(); + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + app.open_remove_linked_worktree_confirmation(0); + app.pending_api_worktree_removes + .insert(workspace_id.clone(), 1); + + app.submit_worktree_remove_via_api(); + + let remove = app.state.worktree_remove.as_ref().unwrap(); + assert_eq!(remove.workspace_id, workspace_id); + assert!(!remove.removing); + assert_eq!( + remove.error.as_deref(), + Some("worktree operation is already in progress for this checkout") + ); + } + #[tokio::test] async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() { let repo = create_committed_repo("app-worktree-create-event-repo"); diff --git a/src/layout.rs b/src/layout.rs index cd611d10..8a16da9c 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -206,8 +206,8 @@ impl TileLayout { } /// Set the ratio of a split node at the given path. - pub fn set_ratio_at(&mut self, path: &[bool], ratio: f32) { - set_ratio_at(&mut self.root, path, ratio.clamp(0.1, 0.9)); + pub fn set_ratio_at(&mut self, path: &[bool], ratio: f32) -> bool { + set_ratio_at(&mut self.root, path, ratio.clamp(0.1, 0.9)) } /// Adjust the nearest split in the given direction for the focused pane. @@ -577,7 +577,7 @@ fn remove_pane(node: Node, target: PaneId) -> Option { } } -fn set_ratio_at(node: &mut Node, path: &[bool], new_ratio: f32) { +fn set_ratio_at(node: &mut Node, path: &[bool], new_ratio: f32) -> bool { if let Node::Split { ratio, first, @@ -587,11 +587,14 @@ fn set_ratio_at(node: &mut Node, path: &[bool], new_ratio: f32) { { if path.is_empty() { *ratio = new_ratio; + true } else if path[0] { - set_ratio_at(second, &path[1..], new_ratio); + set_ratio_at(second, &path[1..], new_ratio) } else { - set_ratio_at(first, &path[1..], new_ratio); + set_ratio_at(first, &path[1..], new_ratio) } + } else { + false } }