refactor: route tui mutations through runtime authority

This commit is contained in:
Ogulcan Celik
2026-06-29 18:09:25 +03:00
parent a937f7e85b
commit 1a4e94e57e
22 changed files with 1885 additions and 115 deletions
+4
View File
@@ -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(_)
+8
View File
@@ -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")]
+10
View File
@@ -126,6 +126,16 @@ pub struct LayoutApplyParams {
pub root: LayoutNode,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LayoutSetSplitRatioParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<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,
+6
View File
@@ -30,6 +30,12 @@ pub struct TabRenameParams {
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabMoveParams {
pub tab_id: String,
pub insert_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TabInfo {
pub tab_id: String,
+52
View File
@@ -702,6 +702,58 @@ fn layout_export_apply_round_trip() {
assert_eq!(restored, response);
}
#[test]
fn authority_mutation_requests_round_trip() {
let workspace_move = Request {
id: "move_ws".into(),
method: Method::WorkspaceMove(WorkspaceMoveParams {
workspace_id: "w1".into(),
insert_index: 2,
}),
};
let json = serde_json::to_value(&workspace_move).unwrap();
assert_eq!(json["method"], "workspace.move");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, workspace_move);
let tab_move = Request {
id: "move_tab".into(),
method: Method::TabMove(TabMoveParams {
tab_id: "w1:1".into(),
insert_index: 1,
}),
};
let json = serde_json::to_value(&tab_move).unwrap();
assert_eq!(json["method"], "tab.move");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, tab_move);
let pane_focus = Request {
id: "focus_pane".into(),
method: Method::PaneFocus(PaneTarget {
pane_id: "w1:1".into(),
}),
};
let json = serde_json::to_value(&pane_focus).unwrap();
assert_eq!(json["method"], "pane.focus");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, pane_focus);
let split_ratio = Request {
id: "set_ratio".into(),
method: Method::LayoutSetSplitRatio(LayoutSetSplitRatioParams {
tab_id: Some("w1:1".into()),
pane_id: None,
path: vec![false, true],
ratio: 0.6,
}),
};
let json = serde_json::to_value(&split_ratio).unwrap();
assert_eq!(json["method"], "layout.set_split_ratio");
let restored: Request = serde_json::from_value(json).unwrap();
assert_eq!(restored, split_ratio);
}
#[test]
fn create_response_round_trips_with_root_pane() {
let response = SuccessResponse {
+6
View File
@@ -22,6 +22,12 @@ pub struct WorkspaceRenameParams {
pub label: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceMoveParams {
pub workspace_id: String,
pub insert_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceInfo {
pub workspace_id: String,
+4
View File
@@ -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",
+6 -13
View File
@@ -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;
+38
View File
@@ -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) => {
+76 -1
View File
@@ -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();
+62
View File
@@ -161,6 +161,23 @@ impl App {
encode_success(id, ResponseResult::PaneInfo { pane })
}
pub(super) fn handle_pane_focus(&mut self, id: String, target: PaneTarget) -> String {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
return pane_not_found(id, &target.pane_id);
};
let Some(_tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else {
return pane_not_found(id, &target.pane_id);
};
self.state.focus_pane_in_workspace(ws_idx, pane_id);
self.state.mode = Mode::Terminal;
let Some(pane) = self.pane_info(ws_idx, pane_id) else {
return pane_not_found(id, &target.pane_id);
};
encode_success(id, ResponseResult::PaneInfo { pane })
}
pub(super) fn handle_pane_layout(&mut self, id: String, params: PaneLayoutParams) -> String {
let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else {
return encode_error(id, "pane_not_found", "pane not found");
@@ -3203,6 +3220,51 @@ mod tests {
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right));
}
#[test]
fn api_pane_focus_focuses_direct_target_across_tabs_and_workspaces() {
let mut app = app_with_linked_worktree();
app.state.workspaces.push(Workspace::test_new("other"));
let target_tab_idx = app.state.workspaces[1].test_add_tab(Some("target"));
app.state.workspaces[1].switch_tab(target_tab_idx);
let target_pane = app.state.workspaces[1].tabs[target_tab_idx].root_pane;
app.state.ensure_test_terminals();
let target_public = app.public_pane_id(1, target_pane).unwrap();
app.state.switch_workspace(0);
assert_eq!(app.state.active, Some(0));
let response = app.handle_pane_focus(
"req".into(),
crate::api::schema::PaneTarget {
pane_id: target_public.clone(),
},
);
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
let ResponseResult::PaneInfo { pane } = success.result else {
panic!("expected pane info response");
};
assert_eq!(pane.pane_id, target_public);
assert_eq!(app.state.active, Some(1));
assert_eq!(app.state.workspaces[1].active_tab, target_tab_idx);
assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane));
assert_eq!(app.state.mode, Mode::Terminal);
}
#[test]
fn api_pane_focus_rejects_invalid_pane_id() {
let mut app = app_with_linked_worktree();
let response = app.handle_pane_focus(
"req".into(),
crate::api::schema::PaneTarget {
pane_id: "pane_missing".into(),
},
);
let error: ErrorResponse = serde_json::from_str(&response).unwrap();
assert_eq!(error.error.code, "pane_not_found");
}
#[test]
fn api_pane_focus_direction_no_neighbor_is_noop() {
let mut app = app_with_linked_worktree();
+87 -1
View File
@@ -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());
}
}
+71 -1
View File
@@ -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");
}
}
+38
View File
@@ -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 {
+62 -22
View File
@@ -56,6 +56,7 @@ use self::{
modal::{
modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS,
},
mouse::MouseAction,
settings::SettingsAction,
};
use super::state::{AppState, Mode};
@@ -86,19 +87,15 @@ impl App {
Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event),
Mode::Prefix | Mode::Navigate | Mode::Copy => unreachable!(),
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
handle_rename_key(&mut self.state, key_event)
self.handle_rename_key_via_api(key_event)
}
Mode::NewLinkedWorktree => self.handle_worktree_create_key(key_event),
Mode::OpenExistingWorktree => self.handle_worktree_open_key(key_event),
Mode::ConfirmRemoveWorktree => self.handle_worktree_remove_key(key_event),
Mode::Resize => handle_resize_key(&mut self.state, key),
Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event),
Mode::Resize => self.handle_resize_key_via_api(key),
Mode::ConfirmClose => self.handle_confirm_close_key_via_api(key_event),
Mode::ContextMenu => {
handle_context_menu_key(
&mut self.state,
&mut self.terminal_runtimes,
key_event,
);
self.handle_context_menu_key_via_api(key_event);
}
Mode::Settings => self.handle_settings_key(key_event),
Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event),
@@ -259,24 +256,67 @@ impl App {
let previous_agent_panel_sort = self.state.agent_panel_sort;
let previous_settings_section = self.state.settings.section;
if !handled_pane_double_click {
let right_button = matches!(
mouse.kind,
MouseEventKind::Down(MouseButton::Right)
| MouseEventKind::Up(MouseButton::Right)
| MouseEventKind::Drag(MouseButton::Right)
);
let intentional_pane_press = matches!(
mouse.kind,
MouseEventKind::Down(MouseButton::Left | MouseButton::Middle)
);
if !right_button
&& intentional_pane_press
&& matches!(self.state.mode, Mode::Terminal | Mode::Resize)
{
if let (Some(ws_idx), Some(info)) = (
self.state.active,
self.state.pane_at(mouse.column, mouse.row).cloned(),
) {
self.focus_pane_internal_via_api(ws_idx, info.id);
}
}
if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
match action {
SettingsAction::SaveTheme(name) => self.save_theme(&name),
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
SettingsAction::SaveToastDelivery(delivery) => {
self.save_toast_delivery(delivery)
MouseAction::Settings(action) => match action {
SettingsAction::SaveTheme(name) => self.save_theme(&name),
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
SettingsAction::SaveToastDelivery(delivery) => {
self.save_toast_delivery(delivery)
}
SettingsAction::SaveAgentBorderLabels(enabled) => {
self.save_agent_border_labels(enabled)
}
SettingsAction::SavePaneHistory(enabled) => {
self.save_pane_history_persistence(enabled)
}
SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => {
self.save_switch_ascii_input_source_in_prefix(enabled)
}
SettingsAction::InstallRecommendedIntegrations => {
self.install_recommended_integrations()
}
},
MouseAction::FocusWorkspace { ws_idx } => {
self.focus_workspace_idx_via_api(ws_idx)
}
SettingsAction::SaveAgentBorderLabels(enabled) => {
self.save_agent_border_labels(enabled)
MouseAction::FocusTab { tab_idx } => self.focus_tab_idx_via_api(tab_idx),
MouseAction::FocusPane { ws_idx, pane_id } => {
self.focus_pane_internal_via_api(ws_idx, pane_id)
}
SettingsAction::SavePaneHistory(enabled) => {
self.save_pane_history_persistence(enabled)
}
SettingsAction::SaveSwitchAsciiInputSourceInPrefix(enabled) => {
self.save_switch_ascii_input_source_in_prefix(enabled)
}
SettingsAction::InstallRecommendedIntegrations => {
self.install_recommended_integrations()
MouseAction::FocusToastTarget => self.focus_toast_target_via_api(),
MouseAction::MoveWorkspace {
source_ws_idx,
insert_idx,
} => self.move_workspace_via_api(source_ws_idx, insert_idx),
MouseAction::MoveTab {
ws_idx,
source_tab_idx,
insert_idx,
} => self.move_tab_via_api(ws_idx, source_tab_idx, insert_idx),
MouseAction::SetSplitRatio { path, ratio } => {
self.set_split_ratio_via_api(path, ratio)
}
}
}
+366 -2
View File
@@ -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();
+94 -39
View File
@@ -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 {
+656 -16
View File
@@ -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);
}
+1 -6
View File
@@ -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;
}
+36 -9
View File
@@ -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;
}
+194
View File
@@ -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");
+8 -5
View File
@@ -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
}
}