aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-29 18:09:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-29 18:09:25 +0300
commit1a4e94e57e181379e2ed42d63a49fbc56ed1dba4 (patch)
tree0319544374f1149a405259070b90df9d62854789
parenta937f7e85bbf8780c6030c57f48dab2168bca932 (diff)
refactor: route tui mutations through runtime authority
-rw-r--r--src/api/mod.rs4
-rw-r--r--src/api/schema.rs8
-rw-r--r--src/api/schema/panes.rs10
-rw-r--r--src/api/schema/tabs.rs6
-rw-r--r--src/api/schema/tests.rs52
-rw-r--r--src/api/schema/workspaces.rs6
-rw-r--r--src/api/server.rs4
-rw-r--r--src/app/actions.rs19
-rw-r--r--src/app/api.rs38
-rw-r--r--src/app/api/layouts.rs77
-rw-r--r--src/app/api/panes.rs62
-rw-r--r--src/app/api/tabs.rs88
-rw-r--r--src/app/api/workspaces.rs72
-rw-r--r--src/app/api/worktrees/deferred.rs38
-rw-r--r--src/app/input/mod.rs84
-rw-r--r--src/app/input/modal.rs368
-rw-r--r--src/app/input/mouse.rs133
-rw-r--r--src/app/input/navigate.rs672
-rw-r--r--src/app/input/terminal.rs7
-rw-r--r--src/app/mod.rs45
-rw-r--r--src/app/worktrees.rs194
-rw-r--r--src/layout.rs13
22 files changed, 1885 insertions, 115 deletions
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
@@ -127,6 +127,16 @@ pub struct LayoutApplyParams {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct LayoutSetSplitRatioParams {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pane_id: Option<String>,
+ pub path: Vec<bool>,
+ pub ratio: f32,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutDescription {
pub workspace_id: String,
pub tab_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
@@ -31,6 +31,12 @@ pub struct TabRenameParams {
}
#[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,
pub workspace_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
@@ -703,6 +703,58 @@ fn layout_export_apply_round_trip() {
}
#[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 {
id: "req_2".into(),
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
@@ -23,6 +23,12 @@ pub struct WorkspaceRenameParams {
}
#[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,
pub number: usize,
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<String> {
+ 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(&params.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");
@@ -3204,6 +3221,51 @@ mod tests {
}
#[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();
let root = app.state.workspaces[0].tabs[0].root_pane;
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(&params.tab_id) else {
+ return tab_not_found(id, &params.tab_id);
+ };
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return tab_not_found(id, &params.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(&params.workspace_id) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ if self.state.workspaces.get(index).is_none() {
+ return workspace_not_found(id, &params.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)
- }
- SettingsAction::SaveAgentBorderLabels(enabled) => {
- self.save_agent_border_labels(enabled)
- }
- SettingsAction::SavePaneHistory(enabled) => {
- self.save_pane_history_persistence(enabled)
+ 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::SaveSwitchAsciiInputSourceInPrefix(enabled) => {
- self.save_switch_ascii_input_source_in_prefix(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::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<GlobalMenuAction> {
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<bool>,
+ 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<SettingsAction> {
+ ) -> Option<MouseAction> {
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<bool>, 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<usize> {
+ 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<usize> {
+ 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<NavigateAction> {
+ 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::<crate::api::schema::SuccessResponse>(&response).is_ok() {
+ self.state.worktree_open = None;
+ self.state.mode = Mode::Terminal;
+ } else if let Ok(error) =
+ serde_json::from_str::<crate::api::schema::ErrorResponse>(&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<String> {
+ response
+ .and_then(|response| {
+ serde_json::from_str::<crate::api::schema::ErrorResponse>(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<Node> {
}
}
-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
}
}