aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-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
5 files changed, 334 insertions, 3 deletions
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 {