mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
refactor: route tui mutations through runtime authority
This commit is contained in:
@@ -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(_)
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user