aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/agent_view.rs4
-rw-r--r--src/app/api/agents.rs2
-rw-r--r--src/app/api/integrations.rs29
-rw-r--r--src/app/api/layouts.rs2
-rw-r--r--src/app/api/pane_graphics.rs16
-rw-r--r--src/app/api/panes.rs867
-rw-r--r--src/app/api/plugins/context.rs24
-rw-r--r--src/app/api/plugins/mod.rs196
-rw-r--r--src/app/api/responses.rs4
-rw-r--r--src/app/api/session.rs4
-rw-r--r--src/app/api/tabs.rs66
-rw-r--r--src/app/api/workspaces.rs171
-rw-r--r--src/app/api/worktrees.rs17
-rw-r--r--src/app/api/worktrees/deferred.rs38
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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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, &params.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(&params.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(&params) {
+ 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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(
+ &params.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(&params.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(&copy_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 {