aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/panes.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/api/panes.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/api/panes.rs')
-rw-r--r--src/app/api/panes.rs867
1 files changed, 616 insertions, 251 deletions
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(