diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-29 18:09:25 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-29 18:09:25 +0300 |
| commit | 1a4e94e57e181379e2ed42d63a49fbc56ed1dba4 (patch) | |
| tree | 0319544374f1149a405259070b90df9d62854789 /src/app/api | |
| parent | a937f7e85bbf8780c6030c57f48dab2168bca932 (diff) | |
refactor: route tui mutations through runtime authority
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/layouts.rs | 77 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 62 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 88 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 72 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 38 |
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(¶ms.path, params.ratio)); + if !changed { + return encode_error(id, "split_not_found", "split path not found"); + } + + self.schedule_session_save(); + let Some(layout) = self.layout_description(ws_idx, tab_idx) else { + return encode_error(id, "layout_not_found", "layout unavailable"); + }; + encode_success(id, ResponseResult::LayoutApply { layout }) + } + fn resolve_layout_export_target(&self, params: &LayoutExportParams) -> Option<(usize, usize)> { match (params.tab_id.as_deref(), params.pane_id.as_deref()) { (Some(_), Some(_)) => None, @@ -636,6 +668,49 @@ mod tests { assert_eq!(pane.pane_id, Some(app.public_pane_id(0, right).unwrap())); } + #[test] + fn layout_set_split_ratio_updates_existing_split() { + let mut app = app_with_workspace(); + app.state.workspaces[0].test_split(Direction::Horizontal); + + let response = app.handle_layout_set_split_ratio( + "req".into(), + LayoutSetSplitRatioParams { + tab_id: None, + pane_id: None, + path: vec![], + ratio: 0.72, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::LayoutApply { layout } = success.result else { + panic!("expected layout apply response"); + }; + let LayoutNode::Split { ratio, .. } = layout.root else { + panic!("expected split layout root"); + }; + assert!((ratio - 0.72).abs() < f32::EPSILON); + } + + #[test] + fn layout_set_split_ratio_rejects_missing_split() { + let mut app = app_with_workspace(); + + let response = app.handle_layout_set_split_ratio( + "req".into(), + LayoutSetSplitRatioParams { + tab_id: None, + pane_id: None, + path: vec![], + ratio: 0.72, + }, + ); + + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "split_not_found"); + } + #[tokio::test] async fn layout_apply_replaces_tab_with_requested_tree() { let mut app = app_with_workspace(); diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 983db238..b2f5030a 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -161,6 +161,23 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_focus(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let Some(_tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + + self.state.focus_pane_in_workspace(ws_idx, pane_id); + self.state.mode = Mode::Terminal; + + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + encode_success(id, ResponseResult::PaneInfo { pane }) + } + pub(super) fn handle_pane_layout(&mut self, id: String, params: PaneLayoutParams) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); @@ -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(¶ms.tab_id) else { + return tab_not_found(id, ¶ms.tab_id); + }; + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return tab_not_found(id, ¶ms.tab_id); + }; + if params.insert_index > ws.tabs.len() { + return encode_error( + id, + "tab_move_failed", + format!("insert_index {} is out of bounds", params.insert_index), + ); + } + + let moved = self + .state + .workspaces + .get_mut(ws_idx) + .is_some_and(|ws| ws.move_tab(tab_idx, params.insert_index)); + if moved { + self.schedule_session_save(); + if self.state.active == Some(ws_idx) { + self.state.tab_scroll_follow_active = true; + self.state.refresh_tab_bar_view(); + } + } + + encode_success( + id, + ResponseResult::TabList { + tabs: self + .state + .workspaces + .get(ws_idx) + .map(|ws| { + (0..ws.tabs.len()) + .filter_map(|idx| self.tab_info(ws_idx, idx)) + .collect() + }) + .unwrap_or_default(), + }, + ) + } + pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String { let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else { return tab_not_found(id, &target.tab_id); @@ -237,3 +282,44 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { fn tab_not_found(id: String, tab_id: &str) -> String { encode_error(id, "tab_not_found", format!("tab {tab_id} not found")) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + + #[test] + fn api_tab_move_reorders_tabs_in_target_workspace() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + let mut workspace = Workspace::test_new("tabs"); + workspace.test_add_tab(Some("two")); + workspace.test_add_tab(Some("three")); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + let moved_root = app.state.workspaces[0].tabs[0].root_pane; + let moved_id = app.public_tab_id(0, 0).unwrap(); + + let response = app.handle_tab_move( + "req".into(), + TabMoveParams { + tab_id: moved_id, + insert_index: 3, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::TabList { tabs } = success.result else { + panic!("expected tab list"); + }; + assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root); + assert_eq!(tabs[2].tab_id, app.public_tab_id(0, 2).unwrap()); + } +} diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index d1d7bf5c..56427463 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use crate::api::schema::{ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams, - WorkspaceRenameParams, WorkspaceTarget, + WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, }; use crate::app::App; @@ -121,6 +121,41 @@ impl App { ) } + pub(super) fn handle_workspace_move( + &mut self, + id: String, + params: WorkspaceMoveParams, + ) -> String { + let Some(index) = self.parse_workspace_id(¶ms.workspace_id) else { + return workspace_not_found(id, ¶ms.workspace_id); + }; + if self.state.workspaces.get(index).is_none() { + return workspace_not_found(id, ¶ms.workspace_id); + } + if params.insert_index > self.state.workspaces.len() { + return encode_error( + id, + "workspace_move_failed", + format!("insert_index {} is out of bounds", params.insert_index), + ); + } + + self.state.move_workspace(index, params.insert_index); + + encode_success( + id, + ResponseResult::WorkspaceList { + workspaces: self + .state + .workspaces + .iter() + .enumerate() + .map(|(idx, _)| self.workspace_info(idx)) + .collect(), + }, + ) + } + pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String { let Some(index) = self.parse_workspace_id(&target.workspace_id) else { return workspace_not_found(id, &target.workspace_id); @@ -241,4 +276,39 @@ mod tests { ) })); } + + #[test] + fn api_workspace_move_reorders_workspaces() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.workspaces = vec![ + Workspace::test_new("one"), + Workspace::test_new("two"), + Workspace::test_new("three"), + ]; + app.state.active = Some(0); + app.state.selected = 0; + let moved_id = app.public_workspace_id(0); + + let response = app.handle_workspace_move( + "req".into(), + WorkspaceMoveParams { + workspace_id: moved_id.clone(), + insert_index: 3, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::WorkspaceList { workspaces } = success.result else { + panic!("expected workspace list"); + }; + assert_eq!(workspaces[2].workspace_id, moved_id); + assert_eq!(app.state.workspaces[2].display_name(), "one"); + } } diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index f63a47fd..516e9455 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -366,6 +366,12 @@ impl App { self.pending_api_worktree_creates.remove(&checkout_key); if let Err(err) = result.result { + if let Some(create) = &mut self.state.worktree_create { + if create.checkout_path == result.path { + create.creating = false; + create.error = Some(err.clone()); + } + } Self::send_api_response( api.respond_to, encode_error(api.id, "worktree_create_failed", err), @@ -421,6 +427,17 @@ impl App { ws.set_custom_name(label); } } + if self + .state + .worktree_create + .as_ref() + .is_some_and(|create| create.checkout_path == result.path) + { + self.state.worktree_create = None; + self.state.name_input.clear(); + self.state.name_input_replace_on_type = false; + self.state.mode = crate::app::Mode::Terminal; + } self.state.mark_session_dirty(); if created_workspace { self.emit_workspace_open_events(ws_idx); @@ -492,6 +509,17 @@ impl App { } else { "worktree_remove_failed" }; + if let Some(remove) = &mut self.state.worktree_remove { + if remove.workspace_id == result.workspace_id && remove.path == result.path { + remove.removing = false; + if code == "dirty_worktree_requires_force" && !remove.force_confirmation { + remove.force_confirmation = true; + remove.error = None; + } else { + remove.error = Some(message.clone()); + } + } + } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; } @@ -554,6 +582,16 @@ impl App { worktree, result.forced, ); + if self.state.worktree_remove.as_ref().is_some_and(|remove| { + remove.workspace_id == result.workspace_id && remove.path == result.path + }) { + self.state.worktree_remove = None; + self.state.mode = if self.state.active.is_some() { + crate::app::Mode::Terminal + } else { + crate::app::Mode::Navigate + }; + } let response = encode_success( api.id, ResponseResult::WorktreeRemoved { |
