diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/agent_view.rs | 4 | ||||
| -rw-r--r-- | src/app/api/agents.rs | 2 | ||||
| -rw-r--r-- | src/app/api/integrations.rs | 29 | ||||
| -rw-r--r-- | src/app/api/layouts.rs | 2 | ||||
| -rw-r--r-- | src/app/api/pane_graphics.rs | 16 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 867 | ||||
| -rw-r--r-- | src/app/api/plugins/context.rs | 24 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 196 | ||||
| -rw-r--r-- | src/app/api/responses.rs | 4 | ||||
| -rw-r--r-- | src/app/api/session.rs | 4 | ||||
| -rw-r--r-- | src/app/api/tabs.rs | 66 | ||||
| -rw-r--r-- | src/app/api/workspaces.rs | 171 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 17 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 38 |
14 files changed, 946 insertions, 494 deletions
diff --git a/src/app/api/agent_view.rs b/src/app/api/agent_view.rs index e14a933d..c8c48926 100644 --- a/src/app/api/agent_view.rs +++ b/src/app/api/agent_view.rs @@ -87,8 +87,6 @@ impl App { fn replace_agent_view_override(&mut self, view: Option<AgentViewSetParams>) { self.state.agent_view_override = view; - self.state.agent_panel_scroll = 0; - self.state.mobile_switcher_scroll = 0; } } @@ -103,7 +101,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs index 1ed13efc..65a65863 100644 --- a/src/app/api/agents.rs +++ b/src/app/api/agents.rs @@ -319,7 +319,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs index 0cca8cba..ba1603e2 100644 --- a/src/app/api/integrations.rs +++ b/src/app/api/integrations.rs @@ -1,9 +1,34 @@ -use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult}; +use crate::api::schema::{ + IntegrationInfo, IntegrationInstallResult, IntegrationState, IntegrationUninstallResult, + ResponseResult, +}; use crate::app::App; use super::responses::{encode_error, encode_success}; impl App { + pub(super) fn handle_integration_list(&self, id: String) -> String { + let integrations = crate::integration::integration_recommendations() + .into_iter() + .map(|recommendation| IntegrationInfo { + target: recommendation.target, + label: recommendation.label.to_owned(), + command: recommendation.command.to_owned(), + available: recommendation.available, + state: match recommendation.state { + crate::integration::IntegrationStatusKind::NotInstalled => { + IntegrationState::NotInstalled + } + crate::integration::IntegrationStatusKind::Current => IntegrationState::Current, + crate::integration::IntegrationStatusKind::Outdated => { + IntegrationState::Outdated + } + }, + }) + .collect(); + encode_success(id, ResponseResult::IntegrationList { integrations }) + } + pub(super) fn handle_integration_install( &mut self, id: String, @@ -14,6 +39,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_install_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, @@ -34,6 +60,7 @@ impl App { Ok(messages) => messages, Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()), }; + self.state.integration_recommendations = crate::integration::integration_recommendations(); encode_success( id, diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index 9d2756b8..943ed835 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -608,7 +608,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs index 4fc1deec..3dddccc9 100644 --- a/src/app/api/pane_graphics.rs +++ b/src/app/api/pane_graphics.rs @@ -18,17 +18,7 @@ impl App { /// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not /// suspend the producer because the pane becomes visible again without a layout event. fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool { - if self.state.active != Some(ws_idx) { - return false; - } - let Some(tab) = self.state.workspaces[ws_idx].active_tab() else { - return false; - }; - if tab.zoomed { - tab.layout.focused() == pane_id - } else { - tab.layout.pane_ids().contains(&pane_id) - } + self.state.pane_visible_on_active_surface(ws_idx, pane_id) } pub(super) fn handle_pane_graphics_info( @@ -566,7 +556,7 @@ mod tests { let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, rx, crate::api::EventHub::default(), @@ -665,7 +655,7 @@ mod tests { } #[test] - fn monolithic_info_keeps_fast_transport_and_exact_pixels_disabled() { + fn non_persistent_info_keeps_fast_transport_and_exact_pixels_disabled() { let (mut app, pane_id) = app(); app.state.host_cell_size = crate::kitty_graphics::HostCellSize { width_px: 10, diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 6615fa0a..f1c3a667 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -1,7 +1,8 @@ use bytes::Bytes; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams, + EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCopyMotion, + PaneCopyMotionParams, PaneCopySearchDirection, PaneCopySearchParams, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneInputSetParams, PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, @@ -10,8 +11,9 @@ use crate::api::schema::{ PaneProcessInfoProcess, PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, - PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, - PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, + PaneScrollParams, PaneSelectionReadParams, PaneSendInputParams, PaneSendKeysParams, + PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, + PaneTarget, PaneTextPoint, PaneTextRange, PaneZoomMode, PaneZoomParams, PaneZoomReason, PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; @@ -112,7 +114,7 @@ impl App { self.state.switch_workspace_tab(ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); @@ -165,6 +167,304 @@ impl App { encode_success(id, ResponseResult::PaneInfo { pane }) } + pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + runtime.set_scroll_offset_from_bottom( + usize::try_from(params.offset_from_bottom).unwrap_or(usize::MAX), + ); + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_edit_scrollback(&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 is_focused = self.state.active == Some(ws_idx) + && self + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + == Some(pane_id); + if !is_focused { + return encode_error(id, "stale_pane_target", "pane is no longer focused"); + } + match self.open_focused_scrollback_in_editor() { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(err) => encode_error(id, "scrollback_editor_failed", err.to_string()), + } + } + + pub(crate) fn pane_selection_text( + &self, + params: &PaneSelectionReadParams, + ) -> Result<String, (&'static str, String)> { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return Err(("stale_content", "pane content changed".to_owned())); + } + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.anchor.row, params.anchor.col), + (params.cursor.row, params.cursor.col), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return Err(( + "selection_unavailable", + "selection text is unavailable".to_owned(), + )); + }; + if params.content_revision.is_some() && runtime.content_seq() != before { + return Err(("stale_content", "pane content changed".to_owned())); + } + Ok(text) + } + + pub(super) fn handle_pane_selection_read( + &mut self, + id: String, + params: PaneSelectionReadParams, + ) -> String { + match self.pane_selection_text(¶ms) { + Ok(text) => encode_success( + id, + ResponseResult::PaneSelection { + pane_id: params.pane_id, + text, + }, + ), + Err((code, message)) => encode_error(id, code, message), + } + } + + pub(super) fn handle_pane_copy_motion( + &mut self, + id: String, + params: PaneCopyMotionParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return encode_error(id, "stale_content", "pane content changed"); + } + let target = match params.motion { + PaneCopyMotion::LineEnd | PaneCopyMotion::FirstNonBlank => { + let width = runtime + .terminal_dimensions() + .map_or(1, |(cols, _)| cols.max(1)); + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.cursor.row, 0), + (params.cursor.row, width.saturating_sub(1)), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return encode_error( + id, + "copy_motion_unavailable", + "terminal row is unavailable", + ); + }; + let col = match params.motion { + PaneCopyMotion::LineEnd => { + crate::copy_mode::last_character_col(&text).unwrap_or(0) + } + PaneCopyMotion::FirstNonBlank => { + crate::copy_mode::first_non_blank_col(&text).unwrap_or(0) + } + _ => unreachable!(), + }; + crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: col.min(width.saturating_sub(1)), + } + } + PaneCopyMotion::NextWordStart + | PaneCopyMotion::PreviousWordStart + | PaneCopyMotion::NextWordEnd + | PaneCopyMotion::NextBigWordStart + | PaneCopyMotion::PreviousBigWordStart + | PaneCopyMotion::NextBigWordEnd => { + let motion = match params.motion { + PaneCopyMotion::NextWordStart => crate::pane::TerminalWordMotion::NextStart, + PaneCopyMotion::PreviousWordStart => { + crate::pane::TerminalWordMotion::PreviousStart + } + PaneCopyMotion::NextWordEnd => crate::pane::TerminalWordMotion::NextEnd, + PaneCopyMotion::NextBigWordStart => { + crate::pane::TerminalWordMotion::NextBigStart + } + PaneCopyMotion::PreviousBigWordStart => { + crate::pane::TerminalWordMotion::PreviousBigStart + } + PaneCopyMotion::NextBigWordEnd => crate::pane::TerminalWordMotion::NextBigEnd, + _ => unreachable!(), + }; + runtime + .word_motion_target(params.cursor.row, params.cursor.col, motion) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }) + } + PaneCopyMotion::PreviousParagraph | PaneCopyMotion::NextParagraph => runtime + .paragraph_motion_target( + params.cursor.row, + if params.motion == PaneCopyMotion::PreviousParagraph { + -1 + } else { + 1 + }, + ) + .map(|target| crate::pane::TerminalTextPoint { + row: target.row, + col: params.cursor.col, + }) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }), + }; + let after = runtime.content_seq(); + if params.content_revision.is_some() && after != before { + return encode_error(id, "stale_content", "pane content changed"); + } + encode_success( + id, + ResponseResult::PaneCopyMotion { + pane_id: params.pane_id, + cursor: crate::api::schema::PaneTextPoint { + row: target.row, + col: target.col, + }, + content_revision: after, + }, + ) + } + + pub(super) fn handle_pane_copy_search( + &mut self, + id: String, + params: PaneCopySearchParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + const MAX_QUERY_BYTES: usize = 4096; + const MAX_RETURNED_MATCHES: usize = 1024; + if params.query.len() > MAX_QUERY_BYTES { + return encode_error(id, "query_too_large", "copy search query is too large"); + } + let before = runtime.content_seq(); + if before != params.content_revision || !before.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let cursor = crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }; + let previous = params.previous.map(|previous| { + ( + crate::pane::TerminalTextPoint { + row: previous.start.row, + col: previous.start.col, + }, + crate::pane::TerminalTextPoint { + row: previous.end.row, + col: previous.end.col, + }, + ) + }); + let direction = match params.direction { + PaneCopySearchDirection::Forward => crate::pane::TerminalSearchDirection::Forward, + PaneCopySearchDirection::Backward => crate::pane::TerminalSearchDirection::Backward, + }; + let result = runtime.search_text_window( + ¶ms.query, + params.query.chars().any(char::is_uppercase), + direction, + cursor, + previous, + MAX_RETURNED_MATCHES, + ); + let after = runtime.content_seq(); + if after != before || !after.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let matches = result + .matches + .into_iter() + .map(|text_match| PaneTextRange { + start: PaneTextPoint { + row: text_match.start.row, + col: text_match.start.col, + }, + end: PaneTextPoint { + row: text_match.end.row, + col: text_match.end.col, + }, + }) + .collect(); + encode_success( + id, + ResponseResult::PaneCopySearch { + pane_id: params.pane_id, + content_revision: after, + matches, + total: u64::try_from(result.total).unwrap_or(u64::MAX), + current: result.current.and_then(|index| u32::try_from(index).ok()), + current_global: result + .current_global + .and_then(|index| u64::try_from(index).ok()), + }, + ) + } + 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); @@ -175,7 +475,7 @@ impl App { self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); @@ -367,7 +667,7 @@ impl App { if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let focused_pane_id = self .state @@ -950,7 +1250,7 @@ impl App { .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { @@ -1107,7 +1407,7 @@ impl App { if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; @@ -1912,7 +2212,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2076,6 +2376,272 @@ mod tests { } #[tokio::test] + async fn api_pane_scroll_sets_and_clamps_endpoint_owned_history() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + let max_offset = runtime + .scroll_metrics() + .expect("scroll metrics") + .max_offset_from_bottom; + + let response = app.handle_pane_scroll( + "req".into(), + PaneScrollParams { + pane_id: public_pane_id, + offset_from_bottom: u64::MAX, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info response"); + }; + assert_eq!( + pane.scroll.expect("scroll metrics").offset_from_bottom, + max_offset as u64 + ); + } + + #[tokio::test] + async fn api_pane_selection_read_uses_endpoint_terminal_text() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_selection_read( + "req".into(), + PaneSelectionReadParams { + pane_id: public_pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneSelection { + pane_id: public_pane_id, + text: "hello".into(), + } + ); + } + + #[tokio::test] + async fn api_copy_motion_uses_endpoint_terminal_word_semantics() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + motion: PaneCopyMotion::NextWordStart, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 6 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_paragraph_motion_preserves_the_copy_cursor_column() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"one\r\n\r\nthree", + ), + ); + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: PaneTextPoint { row: 0, col: 2 }, + motion: PaneCopyMotion::NextParagraph, + content_revision: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: PaneTextPoint { row: 1, col: 2 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_copy_search_uses_endpoint_terminal_matches_and_wraps() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta alpha", + ), + ); + + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id.clone(), + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { + pane_id, + matches, + current, + total, + current_global, + .. + } = success.result + else { + panic!("expected copy search response"); + }; + assert_eq!(pane_id, public_pane_id); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].start, PaneTextPoint { row: 0, col: 0 }); + assert_eq!(matches[1].start, PaneTextPoint { row: 0, col: 11 }); + assert_eq!(current, Some(1)); + assert_eq!(current_global, Some(1)); + assert_eq!(total, 2); + } + + #[tokio::test] + async fn api_copy_search_bounds_returned_matches_but_keeps_exact_total() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let text = "a ".repeat(1500); + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 200, + 20, + 4000, + text.as_bytes(), + ), + ); + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "a".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { matches, total, .. } = success.result else { + panic!("expected copy search response"); + }; + assert_eq!(total, 1500); + assert_eq!(matches.len(), 1024); + } + + #[tokio::test] + async fn api_copy_search_rejects_stale_content_revision() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta", + ), + ); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision: 2, + previous: None, + }, + ); + assert!(response.contains("stale_content")); + } + + #[tokio::test] + async fn api_edit_scrollback_rejects_a_pane_that_is_no_longer_focused() { + let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); + app.state.active = None; + + let response = app.handle_pane_edit_scrollback( + "req".into(), + PaneTarget { + pane_id: public_pane_id, + }, + ); + + assert_eq!(metadata_error_code(&response), "stale_pane_target"); + assert!(app.overlay_panes.is_empty()); + } + + #[tokio::test] async fn api_pane_read_reports_when_older_rows_are_omitted() { let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); @@ -2255,7 +2821,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2305,7 +2871,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } @@ -2415,7 +2980,11 @@ mod tests { let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); @@ -2443,45 +3012,15 @@ mod tests { } #[test] - fn api_pane_swap_unfocused_source_updates_last_pane_history() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(focused); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - - let response = app.handle_pane_swap( - "req".into(), - PaneSwapParams { - source_pane_id: Some(source_public), - target_pane_id: Some(target_public), - ..PaneSwapParams::default() - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneSwap { swap } = success.result else { - panic!("expected pane swap response"); - }; - assert!(swap.changed); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused)); - } - - #[test] fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( @@ -2637,124 +3176,6 @@ mod tests { Some(&source_terminal) ); } - - #[test] - fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); - let target = app.state.workspaces[0].tabs[target_tab].root_pane; - seed_terminal_states(&mut app); - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); - - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_public, - target_pane_id: Some(target_public), - split: SplitDirection::Right, - ratio: None, - }, - focus: true, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneMove { move_result } = success.result else { - panic!("expected pane move response"); - }; - assert!(move_result.changed); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); - } - - #[tokio::test] - async fn key_release_follows_pane_moved_across_workspaces() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let source_terminal_id = app.state.workspaces[0].tabs[0] - .terminal_id(source) - .unwrap() - .clone(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>15u", - 2, - ); - app.terminal_runtimes.insert(source_terminal_id, runtime); - app.state.workspaces.push(Workspace::test_new("other")); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let source_public = app.public_pane_id(0, source).unwrap(); - let target = app.state.workspaces[1].tabs[0].root_pane; - let target_tab_id = app.public_tab_id(1, 0).unwrap(); - let target_pane_id = app.public_pane_id(1, target).unwrap(); - - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ), - )], - false, - ); - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_id, - target_pane_id: Some(target_pane_id), - split: SplitDirection::Down, - ratio: None, - }, - focus: false, - }, - ); - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!(success.result, ResponseResult::PaneMove { .. })); - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ) - .with_kind(crossterm::event::KeyEventKind::Release), - )], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("forwarded release after pane move"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); @@ -3316,86 +3737,6 @@ mod tests { } #[test] - fn api_pane_zoom_explicit_background_pane_updates_focus_history() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let first = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(first); - let target_public = app.public_pane_id(1, target).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(target_public.clone()), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.changed); - assert!(zoom.zoom_changed); - assert!(zoom.focus_changed); - assert_eq!(zoom.pane_id, target_public); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - assert!(app.state.workspaces[1].tabs[0].zoomed); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first)); - } - - #[test] - fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let _target_other = - app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.workspaces[1].tabs[0].layout.focus_pane(target); - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(source_public), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.focus_changed); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - } - - #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); @@ -3565,7 +3906,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3596,7 +3941,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3623,7 +3972,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_edges( @@ -3650,7 +4003,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(right); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3688,7 +4045,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3796,7 +4157,11 @@ mod tests { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_focus_direction( diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs index efef83b9..8aee1c31 100644 --- a/src/app/api/plugins/context.rs +++ b/src/app/api/plugins/context.rs @@ -357,10 +357,6 @@ impl App { .as_ref() .and_then(|pane| pane.cwd.clone()) .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); - let selected_text = focused_pane - .as_ref() - .and_then(|pane| self.parse_pane_id(&pane.pane_id)) - .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); PluginInvocationContext { workspace_id: Some(workspace.workspace_id), workspace_label: Some(workspace.label), @@ -372,7 +368,9 @@ impl App { focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), - selected_text, + // Selection is client presentation state. Client keybindings provide + // revision-validated coordinates; API callers can provide explicit context. + selected_text: None, invocation_source: Some("api".to_string()), correlation_id: Some(correlation_id.to_string()), clicked_url: None, @@ -380,22 +378,6 @@ impl App { } } - fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option<String> { - let selection = self.state.selection.as_ref()?; - if selection.pane_id != pane_id || !selection.is_visible() { - return None; - } - let terminal_id = self - .state - .workspaces - .iter() - .find_map(|workspace| workspace.terminal_id(pane_id))?; - self.terminal_runtimes - .get(terminal_id) - .and_then(|runtime| runtime.extract_selection(selection)) - .filter(|text| !text.is_empty()) - } - fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { self.state .workspaces diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index ddf3fba6..056315c3 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -6,11 +6,11 @@ mod runtime; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ - InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, - PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, - PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, - PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, - ResponseResult, + InstalledPluginInfo, PaneLinkActivateParams, PluginActionInfo, PluginActionInvokeParams, + PluginActionListParams, PluginLinkParams, PluginListParams, PluginLogListParams, + PluginManifestAction, PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, + PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, + PluginUnlinkParams, ResponseResult, }; use crate::app::App; pub(super) use manifest::normalize_plugin_id; @@ -37,7 +37,7 @@ impl App { } fn refresh_installed_plugins(&mut self) -> std::io::Result<()> { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(()); } let entries = crate::persist::plugin_registry::try_load()?; @@ -49,7 +49,7 @@ impl App { &mut self, mutation: impl FnOnce(&mut crate::app::state::InstalledPluginRegistry) -> T, ) -> std::io::Result<T> { - if self.no_session { + if !self.policy.persist_plugin_registry { return Ok(mutation(&mut self.state.installed_plugins)); } let (result, entries) = crate::persist::plugin_registry::update(|entries| { @@ -225,6 +225,7 @@ impl App { pub(crate) fn invoke_plugin_action_from_keybind( &mut self, action_id: String, + selected_text: Option<String>, ) -> Result<(), String> { self.refresh_installed_plugins() .map_err(|err| format!("failed to load plugin registry: {err}"))?; @@ -241,6 +242,7 @@ impl App { .map_err(|(_, message)| message)?; let mut context = self.current_plugin_context("keybinding"); context.invocation_source = Some("keybinding".to_string()); + context.selected_text = selected_text; self.start_plugin_command( &plugin, Some(action.action_id), @@ -253,6 +255,80 @@ impl App { .map_err(|(_, message)| message) } + pub(super) fn handle_pane_link_activate( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return encode_error(id, "pane_not_found", "pane not found"); + }; + if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) { + return encode_error(id, "stale_target", "pane is no longer visible"); + } + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return encode_error(id, "pane_not_found", "pane runtime not found"); + }; + let current_offset = runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64); + if params + .offset_from_bottom + .is_some_and(|expected| current_offset != Some(expected)) + { + return encode_error( + id, + "stale_content", + "pane viewport changed before link activation", + ); + } + let content_revision = runtime.content_seq(); + if content_revision % 2 != 0 + || params + .content_revision + .is_some_and(|expected| expected != content_revision) + { + return encode_error( + id, + "stale_content", + "pane content changed before link activation", + ); + } + let url = self.state.url_at_pane_surface_cell( + &self.terminal_runtimes, + ws_idx, + pane_id, + params.viewport_row, + params.col, + ); + if runtime.content_seq() != content_revision + || runtime + .scroll_metrics() + .map(|metrics| metrics.offset_from_bottom as u64) + != current_offset + { + return encode_error( + id, + "stale_content", + "pane content or viewport changed during link activation", + ); + } + let handled = match url.as_deref() { + Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { + Ok(handled) => handled, + Err(err) => { + tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); + false + } + }, + None => false, + }; + encode_success(id, ResponseResult::PaneLinkActivated { url, handled }) + } + pub(crate) fn invoke_plugin_link_handler_for_url( &mut self, url: &str, @@ -392,13 +468,8 @@ impl App { "width and height are only supported when placement is popup", ); } - if placement == PluginPanePlacement::Popup && self.state.mode != crate::app::Mode::Terminal - { - return encode_error( - id, - "ui_busy", - "popup panes can only open from the normal workspace view", - ); + if placement == PluginPanePlacement::Popup && self.state.popup_pane.is_some() { + return encode_error(id, "ui_busy", "a popup pane is already open"); } match placement { PluginPanePlacement::Overlay | PluginPanePlacement::Popup => { @@ -457,7 +528,7 @@ impl App { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); } self.state.focus_pane_in_workspace(ws_idx, pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(record) = self.state.plugin_panes.get(&pane_id).cloned() else { return encode_error(id, "plugin_pane_not_found", "plugin pane not found"); }; @@ -716,7 +787,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -1395,69 +1466,6 @@ platforms = ["linux", "macos"] let _ = std::fs::remove_dir_all(root); } - #[test] - fn plugin_pane_open_popup_preserves_existing_ui_modes() { - let mut app = test_app(); - app.state.workspaces = vec![crate::workspace::Workspace::test_new("modal")]; - app.state.ensure_test_terminals(); - app.state.active = Some(0); - app.state.selected = 0; - let root_pane = app.state.workspaces[0].tabs[0].root_pane; - let root = unique_temp_path("plugin-popup-ui-busy"); - write_manifest(&root); - link_manifest(&mut app, &root); - - let open_popup = |app: &mut App, id: &str| { - app.handle_api_request(Request { - id: id.into(), - method: Method::PluginPaneOpen(PluginPaneOpenParams { - plugin_id: "example.worktree-bootstrap".into(), - entrypoint: "board".into(), - placement: Some(PluginPanePlacement::Popup), - width: None, - height: None, - workspace_id: None, - target_pane_id: None, - direction: None, - cwd: None, - focus: true, - env: std::collections::HashMap::new(), - }), - }) - }; - - app.state.mode = crate::app::Mode::Settings; - app.state.settings.original_theme = Some("settings-theme".into()); - let settings_response = open_popup(&mut app, "settings-popup"); - let settings_error: serde_json::Value = serde_json::from_str(&settings_response).unwrap(); - assert_eq!(settings_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Settings); - assert_eq!( - app.state.settings.original_theme.as_deref(), - Some("settings-theme") - ); - assert!(app.state.popup_pane.is_none()); - - let copy_mode = crate::app::state::CopyModeState { - pane_id: root_pane, - cursor_row: 2, - cursor_col: 3, - entry_offset_from_bottom: 4, - selection: None, - search: crate::app::state::CopyModeSearchState::default(), - }; - app.state.mode = crate::app::Mode::Copy; - app.state.copy_mode = Some(copy_mode.clone()); - let copy_response = open_popup(&mut app, "copy-popup"); - let copy_error: serde_json::Value = serde_json::from_str(©_response).unwrap(); - assert_eq!(copy_error["error"]["code"], "ui_busy"); - assert_eq!(app.state.mode, crate::app::Mode::Copy); - assert_eq!(app.state.copy_mode, Some(copy_mode)); - assert!(app.state.popup_pane.is_none()); - - let _ = std::fs::remove_dir_all(root); - } - #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { @@ -1681,7 +1689,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s\n' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PL let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1764,7 +1772,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1843,7 +1851,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1922,7 +1930,7 @@ command = ["sh", "-c", "sleep 1"] let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &crate::config::Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, event_hub.clone(), @@ -1958,8 +1966,8 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] write_manifest_content(&root, &manifest); link_manifest(&mut app, &root); - let open = app.handle_api_request(Request { - id: "pane-open-popup".into(), + let popup_request = |id: &str| Request { + id: id.into(), method: Method::PluginPaneOpen(PluginPaneOpenParams { plugin_id: "example.popup".into(), entrypoint: "board".into(), @@ -1973,8 +1981,14 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] focus: true, env: std::collections::HashMap::new(), }), - }); + }; + app.state.mode = crate::app::Mode::Navigate; + let open = app.handle_api_request(popup_request("pane-open-popup")); assert_eq!(response_result(&open), ResponseResult::Ok {}); + let duplicate = app.handle_api_request(popup_request("pane-open-popup-duplicate")); + let duplicate: crate::api::schema::ErrorResponse = + serde_json::from_str(&duplicate).unwrap(); + assert_eq!(duplicate.error.code, "ui_busy"); assert_eq!( read_capture_when_ready(&env_capture, || { app.drain_internal_events(); @@ -2184,7 +2198,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] .unwrap(); let mut app = test_app(); - app.no_session = false; + app.policy.persist_plugin_registry = true; let workspace = crate::workspace::Workspace::test_new("plugin-refresh"); let pane_id = workspace.tabs[0].root_pane; app.state.workspaces = vec![workspace]; @@ -2202,7 +2216,7 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"] make_stale(&mut app); assert!(app - .invoke_plugin_action_from_keybind("bootstrap".into()) + .invoke_plugin_action_from_keybind("bootstrap".into(), None) .unwrap_err() .contains("disabled")); @@ -2419,7 +2433,7 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG } #[tokio::test] - async fn current_plugin_context_includes_selected_text_for_focused_pane() { + async fn current_plugin_context_leaves_client_owned_selection_empty() { let mut app = test_app(); let workspace = crate::workspace::Workspace::test_new("plugin-selection"); let pane_id = workspace.tabs[0].root_pane; @@ -2433,11 +2447,9 @@ command = ["sh", "-c", "printf '%s\n%s\n%s' \"$HERDR_PLUGIN_ROOT\" \"$HERDR_PLUG terminal_id, crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"hello plugin\n"), ); - app.state.selection = Some(crate::selection::Selection::range(pane_id, 0, 0, 4, None)); - let context = app.current_plugin_context("selection-test"); - assert_eq!(context.selected_text.as_deref(), Some("hello")); + assert_eq!(context.selected_text, None); } #[cfg(unix)] diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs index 7c871c37..3cfa1a5c 100644 --- a/src/app/api/responses.rs +++ b/src/app/api/responses.rs @@ -1,10 +1,10 @@ use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse}; -pub(super) fn encode_success(id: String, result: ResponseResult) -> String { +pub(crate) fn encode_success(id: String, result: ResponseResult) -> String { serde_json::to_string(&SuccessResponse { id, result }).unwrap() } -pub(super) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String { +pub(crate) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String { encode_error_body( id, ErrorBody { diff --git a/src/app/api/session.rs b/src/app/api/session.rs index b2238faa..a7d14b3d 100644 --- a/src/app/api/session.rs +++ b/src/app/api/session.rs @@ -13,7 +13,7 @@ impl App { ) } - fn session_snapshot(&self) -> SessionSnapshot { + pub(crate) fn session_snapshot(&self) -> SessionSnapshot { let focused_workspace_id = self .state .active @@ -66,7 +66,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = crate::app::App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 42d2b029..c1bd86e9 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -157,13 +157,6 @@ impl App { }; tab.set_custom_name(params.label.clone()); crate::logging::tab_renamed(&workspace_id, &tab_id); - if self.state.active == Some(ws_idx) { - // Reflow the tab bar so the new label width takes effect immediately. - // The tab bar renders into cached hit areas; without this refresh the - // old geometry lingers until the next refresh (e.g. a tab switch), - // leaving the visible label stale. Mirrors handle_tab_move. - self.state.refresh_tab_bar_view(); - } self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::TabRenamed, @@ -206,10 +199,6 @@ impl App { let tabs = self.tab_list_info(ws_idx); 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(); - } self.emit_event(EventEnvelope { event: EventKind::TabMoved, data: EventData::TabMoved { @@ -337,7 +326,13 @@ mod tests { fn api_tab_close_last_tab_closes_workspace_and_emits_both_events() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("tabs")]; app.state.active = Some(0); app.state.selected = 0; @@ -384,7 +379,13 @@ mod tests { fn api_tab_move_reorders_tabs_in_target_workspace() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); let mut workspace = Workspace::test_new("tabs"); workspace.test_add_tab(Some("two")); workspace.test_add_tab(Some("three")); @@ -424,42 +425,17 @@ mod tests { })); } - #[test] - fn api_tab_rename_reflows_active_tab_bar() { - let event_hub = crate::api::EventHub::default(); - let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); - let workspace = Workspace::test_new("tabs"); - app.state.workspaces = vec![workspace]; - app.state.active = Some(0); - app.state.selected = 0; - app.state.view.tab_bar_rect = ratatui::layout::Rect::new(0, 0, 60, 1); - app.state.refresh_tab_bar_view(); - - let tab_id = app.public_tab_id(0, 0).unwrap(); - let width_before = app.state.view.tab_hit_areas[0].width; - - app.handle_tab_rename( - "req".into(), - TabRenameParams { - tab_id, - label: "a much longer custom tab label".into(), - }, - ); - - let width_after = app.state.view.tab_hit_areas[0].width; - assert!( - width_after > width_before, - "tab bar should reflow to the new label width immediately: \ - before={width_before}, after={width_after}" - ); - } - #[tokio::test] async fn tab_create_follows_cached_focused_pane_cwd_without_runtime() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ); app.state.default_shell = exiting_test_command().into(); app.state.shell_mode = ShellModeConfig::NonLogin; let workspace = Workspace::test_new("tabs"); diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs index d9e689e3..9b125e57 100644 --- a/src/app/api/workspaces.rs +++ b/src/app/api/workspaces.rs @@ -41,12 +41,25 @@ impl App { id: String, params: WorkspaceCreateParams, ) -> String { + let source_workspace_index = if params.cwd.is_some() { + None + } else { + match params.source_workspace_id.as_deref() { + Some(workspace_id) => match self + .parse_workspace_id(workspace_id) + .filter(|index| self.state.workspaces.get(*index).is_some()) + { + Some(index) => Some(index), + None => return workspace_not_found(id, workspace_id), + }, + None => self.workspace_creation_source(), + } + }; let cwd = params.cwd.map(PathBuf::from).unwrap_or_else(|| { - let follow_cwd = self.workspace_creation_source().and_then(|ws_idx| { - self.focused_pane_cwd_in_workspace(ws_idx) - .or_else(|| self.seed_cwd_from_workspace(ws_idx)) - }); - self.resolve_new_terminal_cwd(follow_cwd) + source_workspace_index.map_or_else( + || self.resolve_new_terminal_cwd(None), + |index| self.resolved_new_workspace_cwd_from(index), + ) }); let extra_env = match super::env::normalize_launch_env(params.env) { Ok(env) => env, @@ -360,7 +373,11 @@ fn workspace_not_found(id: String, workspace_id: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{api::schema::SuccessResponse, config::Config, workspace::Workspace}; + use crate::{ + api::schema::{ErrorResponse, SuccessResponse}, + config::Config, + workspace::Workspace, + }; // `new_cwd = follow` must anchor on the focused pane for every creation // surface. Splits and tabs already do; a new workspace must follow the @@ -373,7 +390,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -419,6 +436,7 @@ mod tests { let response = app.handle_workspace_create( "req".into(), WorkspaceCreateParams { + source_workspace_id: None, cwd: None, focus: false, label: None, @@ -444,11 +462,99 @@ mod tests { let _ = std::fs::remove_dir_all(&focused_cwd); } + #[tokio::test] + async fn workspace_create_uses_explicit_source_workspace() { + use super::super::test_support::{exiting_test_command, shutdown_test_runtimes}; + use crate::config::ShellModeConfig; + + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + crate::api::EventHub::default(), + ); + app.state.default_shell = exiting_test_command().into(); + app.state.shell_mode = ShellModeConfig::NonLogin; + app.state.workspaces = vec![Workspace::test_new("first"), Workspace::test_new("source")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.ensure_test_terminals(); + shutdown_test_runtimes(&mut app); + + let source_cwd = + std::env::temp_dir().join(format!("herdr-ws-explicit-source-{}", std::process::id())); + std::fs::create_dir_all(&source_cwd).unwrap(); + let pane_id = app.state.workspaces[1].focused_pane_id().unwrap(); + let terminal_id = app.state.workspaces[1] + .terminal_id(pane_id) + .cloned() + .unwrap(); + app.state.terminals.get_mut(&terminal_id).unwrap().cwd = source_cwd.clone(); + let source_workspace_id = app.public_workspace_id(1); + + let response = app.handle_workspace_create( + "req".into(), + WorkspaceCreateParams { + source_workspace_id: Some(source_workspace_id), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[2].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + + let invalid = app.handle_workspace_create( + "invalid".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: None, + focus: false, + label: None, + env: Default::default(), + }, + ); + let error: ErrorResponse = serde_json::from_str(&invalid).unwrap(); + assert_eq!(error.error.code, "workspace_not_found"); + + let captured = app.handle_workspace_create( + "captured".into(), + WorkspaceCreateParams { + source_workspace_id: Some("w_999".into()), + cwd: Some(source_cwd.display().to_string()), + focus: false, + label: None, + env: Default::default(), + }, + ); + let success: SuccessResponse = serde_json::from_str(&captured).unwrap(); + assert!(matches!( + success.result, + ResponseResult::WorkspaceCreated { .. } + )); + assert_eq!( + crate::worktree::canonical_or_original(&app.state.workspaces[3].identity_cwd), + crate::worktree::canonical_or_original(&source_cwd) + ); + shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(&source_cwd); + } + fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -604,7 +710,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].display_name(), "parent"); } @@ -613,7 +718,13 @@ mod tests { fn api_workspace_close_event_includes_final_worktree_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = app_with_linked_worktree().state.workspaces; let workspace_id = app.state.workspaces[0].id.clone(); @@ -647,7 +758,13 @@ mod tests { fn workspace_metadata_tokens_patch_clear_and_emit_snapshot() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); @@ -699,7 +816,13 @@ mod tests { fn workspace_token_ttl_expires_through_runtime_and_emits_update() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one")]; let workspace_id = app.public_workspace_id(0); let response = app.handle_workspace_report_metadata( @@ -731,7 +854,13 @@ mod tests { fn api_workspace_move_reorders_workspaces() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("one"), Workspace::test_new("two"), @@ -773,7 +902,13 @@ mod tests { fn api_workspace_move_block_reorders_atomically() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![ Workspace::test_new("child"), Workspace::test_new("normal"), @@ -826,7 +961,13 @@ mod tests { fn api_workspace_move_noop_does_not_emit_event() { let event_hub = crate::api::EventHub::default(); let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone()); + let mut app = App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub.clone(), + ); app.state.workspaces = vec![Workspace::test_new("one"), Workspace::test_new("two")]; let moved_id = app.public_workspace_id(0); diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 4a3e23a8..cf4ca3f9 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -638,14 +638,6 @@ impl App { }); } - #[cfg(test)] - pub(crate) fn emit_worktree_opened_for_workspace(&mut self, ws_idx: usize, already_open: bool) { - let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else { - return; - }; - self.emit_worktree_opened_event(ws_idx, worktree, already_open); - } - fn emit_worktree_opened_event( &mut self, ws_idx: usize, @@ -802,7 +794,13 @@ mod tests { fn test_app_with_event_hub(event_hub: crate::api::EventHub) -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); - App::new(&Config::default(), true, None, api_rx, event_hub) + App::new( + &Config::default(), + crate::app::AppPolicy::TEST, + None, + api_rx, + event_hub, + ) } #[cfg(windows)] @@ -936,6 +934,7 @@ mod tests { assert_eq!(tab.workspace_id, workspace.workspace_id); assert_eq!(root_pane.workspace_id, workspace.workspace_id); assert_eq!(worktree.branch.as_deref(), Some("worktree/api-create")); + assert!(Path::new(&worktree.path).starts_with(&worktree_root)); assert!(Path::new(&worktree.path).join("README.md").exists()); assert_eq!(app.state.workspaces.len(), 2); assert!( diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 4755b38d..110f00c9 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -377,12 +377,6 @@ 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), @@ -438,17 +432,6 @@ 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); @@ -520,17 +503,6 @@ 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; } @@ -592,16 +564,6 @@ 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 { |
