diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-01 15:48:22 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-01 15:48:22 +0300 |
| commit | 207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch) | |
| tree | d7b037ecdf629224a048c841d11659efdc23507d /src/app/api/panes.rs | |
| parent | 8a6d697308162874a236c84aac0b0f8c7ac01f83 (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.rs | 867 |
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(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + runtime.set_scroll_offset_from_bottom( + usize::try_from(params.offset_from_bottom).unwrap_or(usize::MAX), + ); + let Some(pane) = self.pane_info(ws_idx, pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + encode_success(id, ResponseResult::PaneInfo { pane }) + } + + pub(super) fn handle_pane_edit_scrollback(&mut self, id: String, target: PaneTarget) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { + return pane_not_found(id, &target.pane_id); + }; + let is_focused = self.state.active == Some(ws_idx) + && self + .state + .workspaces + .get(ws_idx) + .and_then(crate::workspace::Workspace::focused_pane_id) + == Some(pane_id); + if !is_focused { + return encode_error(id, "stale_pane_target", "pane is no longer focused"); + } + match self.open_focused_scrollback_in_editor() { + Ok(()) => encode_success(id, ResponseResult::Ok {}), + Err(err) => encode_error(id, "scrollback_editor_failed", err.to_string()), + } + } + + pub(crate) fn pane_selection_text( + &self, + params: &PaneSelectionReadParams, + ) -> Result<String, (&'static str, String)> { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return Err(("stale_content", "pane content changed".to_owned())); + } + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.anchor.row, params.anchor.col), + (params.cursor.row, params.cursor.col), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return Err(( + "selection_unavailable", + "selection text is unavailable".to_owned(), + )); + }; + if params.content_revision.is_some() && runtime.content_seq() != before { + return Err(("stale_content", "pane content changed".to_owned())); + } + Ok(text) + } + + pub(super) fn handle_pane_selection_read( + &mut self, + id: String, + params: PaneSelectionReadParams, + ) -> String { + match self.pane_selection_text(¶ms) { + Ok(text) => encode_success( + id, + ResponseResult::PaneSelection { + pane_id: params.pane_id, + text, + }, + ), + Err((code, message)) => encode_error(id, code, message), + } + } + + pub(super) fn handle_pane_copy_motion( + &mut self, + id: String, + params: PaneCopyMotionParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + let before = runtime.content_seq(); + if params + .content_revision + .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) + { + return encode_error(id, "stale_content", "pane content changed"); + } + let target = match params.motion { + PaneCopyMotion::LineEnd | PaneCopyMotion::FirstNonBlank => { + let width = runtime + .terminal_dimensions() + .map_or(1, |(cols, _)| cols.max(1)); + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.cursor.row, 0), + (params.cursor.row, width.saturating_sub(1)), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return encode_error( + id, + "copy_motion_unavailable", + "terminal row is unavailable", + ); + }; + let col = match params.motion { + PaneCopyMotion::LineEnd => { + crate::copy_mode::last_character_col(&text).unwrap_or(0) + } + PaneCopyMotion::FirstNonBlank => { + crate::copy_mode::first_non_blank_col(&text).unwrap_or(0) + } + _ => unreachable!(), + }; + crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: col.min(width.saturating_sub(1)), + } + } + PaneCopyMotion::NextWordStart + | PaneCopyMotion::PreviousWordStart + | PaneCopyMotion::NextWordEnd + | PaneCopyMotion::NextBigWordStart + | PaneCopyMotion::PreviousBigWordStart + | PaneCopyMotion::NextBigWordEnd => { + let motion = match params.motion { + PaneCopyMotion::NextWordStart => crate::pane::TerminalWordMotion::NextStart, + PaneCopyMotion::PreviousWordStart => { + crate::pane::TerminalWordMotion::PreviousStart + } + PaneCopyMotion::NextWordEnd => crate::pane::TerminalWordMotion::NextEnd, + PaneCopyMotion::NextBigWordStart => { + crate::pane::TerminalWordMotion::NextBigStart + } + PaneCopyMotion::PreviousBigWordStart => { + crate::pane::TerminalWordMotion::PreviousBigStart + } + PaneCopyMotion::NextBigWordEnd => crate::pane::TerminalWordMotion::NextBigEnd, + _ => unreachable!(), + }; + runtime + .word_motion_target(params.cursor.row, params.cursor.col, motion) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }) + } + PaneCopyMotion::PreviousParagraph | PaneCopyMotion::NextParagraph => runtime + .paragraph_motion_target( + params.cursor.row, + if params.motion == PaneCopyMotion::PreviousParagraph { + -1 + } else { + 1 + }, + ) + .map(|target| crate::pane::TerminalTextPoint { + row: target.row, + col: params.cursor.col, + }) + .unwrap_or(crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }), + }; + let after = runtime.content_seq(); + if params.content_revision.is_some() && after != before { + return encode_error(id, "stale_content", "pane content changed"); + } + encode_success( + id, + ResponseResult::PaneCopyMotion { + pane_id: params.pane_id, + cursor: crate::api::schema::PaneTextPoint { + row: target.row, + col: target.col, + }, + content_revision: after, + }, + ) + } + + pub(super) fn handle_pane_copy_search( + &mut self, + id: String, + params: PaneCopySearchParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(runtime) = + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + const MAX_QUERY_BYTES: usize = 4096; + const MAX_RETURNED_MATCHES: usize = 1024; + if params.query.len() > MAX_QUERY_BYTES { + return encode_error(id, "query_too_large", "copy search query is too large"); + } + let before = runtime.content_seq(); + if before != params.content_revision || !before.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let cursor = crate::pane::TerminalTextPoint { + row: params.cursor.row, + col: params.cursor.col, + }; + let previous = params.previous.map(|previous| { + ( + crate::pane::TerminalTextPoint { + row: previous.start.row, + col: previous.start.col, + }, + crate::pane::TerminalTextPoint { + row: previous.end.row, + col: previous.end.col, + }, + ) + }); + let direction = match params.direction { + PaneCopySearchDirection::Forward => crate::pane::TerminalSearchDirection::Forward, + PaneCopySearchDirection::Backward => crate::pane::TerminalSearchDirection::Backward, + }; + let result = runtime.search_text_window( + ¶ms.query, + params.query.chars().any(char::is_uppercase), + direction, + cursor, + previous, + MAX_RETURNED_MATCHES, + ); + let after = runtime.content_seq(); + if after != before || !after.is_multiple_of(2) { + return encode_error(id, "stale_content", "pane content changed"); + } + let matches = result + .matches + .into_iter() + .map(|text_match| PaneTextRange { + start: PaneTextPoint { + row: text_match.start.row, + col: text_match.start.col, + }, + end: PaneTextPoint { + row: text_match.end.row, + col: text_match.end.col, + }, + }) + .collect(); + encode_success( + id, + ResponseResult::PaneCopySearch { + pane_id: params.pane_id, + content_revision: after, + matches, + total: u64::try_from(result.total).unwrap_or(u64::MAX), + current: result.current.and_then(|index| u32::try_from(index).ok()), + current_global: result + .current_global + .and_then(|index| u64::try_from(index).ok()), + }, + ) + } + pub(super) fn handle_pane_focus(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); @@ -175,7 +475,7 @@ impl App { self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mark_active_tab_seen(); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); @@ -367,7 +667,7 @@ impl App { if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let focused_pane_id = self .state @@ -950,7 +1250,7 @@ impl App { .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { @@ -1107,7 +1407,7 @@ impl App { if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } - self.state.settle_terminal_mode_after_focus(); + self.state.mode = crate::app::Mode::Terminal; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; @@ -1912,7 +2212,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2076,6 +2376,272 @@ mod tests { } #[tokio::test] + async fn api_pane_scroll_sets_and_clamps_endpoint_owned_history() { + let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + let max_offset = runtime + .scroll_metrics() + .expect("scroll metrics") + .max_offset_from_bottom; + + let response = app.handle_pane_scroll( + "req".into(), + PaneScrollParams { + pane_id: public_pane_id, + offset_from_bottom: u64::MAX, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneInfo { pane } = success.result else { + panic!("expected pane info response"); + }; + assert_eq!( + pane.scroll.expect("scroll metrics").offset_from_bottom, + max_offset as u64 + ); + } + + #[tokio::test] + async fn api_pane_selection_read_uses_endpoint_terminal_text() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_selection_read( + "req".into(), + PaneSelectionReadParams { + pane_id: public_pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneSelection { + pane_id: public_pane_id, + text: "hello".into(), + } + ); + } + + #[tokio::test] + async fn api_copy_motion_uses_endpoint_terminal_word_semantics() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"hello world", + ), + ); + + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + motion: PaneCopyMotion::NextWordStart, + content_revision: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: crate::api::schema::PaneTextPoint { row: 0, col: 6 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_paragraph_motion_preserves_the_copy_cursor_column() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"one\r\n\r\nthree", + ), + ); + let response = app.handle_pane_copy_motion( + "req".into(), + PaneCopyMotionParams { + pane_id: public_pane_id.clone(), + cursor: PaneTextPoint { row: 0, col: 2 }, + motion: PaneCopyMotion::NextParagraph, + content_revision: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!( + success.result, + ResponseResult::PaneCopyMotion { + pane_id: public_pane_id, + cursor: PaneTextPoint { row: 1, col: 2 }, + content_revision: 0, + } + ); + } + + #[tokio::test] + async fn api_copy_search_uses_endpoint_terminal_matches_and_wraps() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta alpha", + ), + ); + + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id.clone(), + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { + pane_id, + matches, + current, + total, + current_global, + .. + } = success.result + else { + panic!("expected copy search response"); + }; + assert_eq!(pane_id, public_pane_id); + assert_eq!(matches.len(), 2); + assert_eq!(matches[0].start, PaneTextPoint { row: 0, col: 0 }); + assert_eq!(matches[1].start, PaneTextPoint { row: 0, col: 11 }); + assert_eq!(current, Some(1)); + assert_eq!(current_global, Some(1)); + assert_eq!(total, 2); + } + + #[tokio::test] + async fn api_copy_search_bounds_returned_matches_but_keeps_exact_total() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let text = "a ".repeat(1500); + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 200, + 20, + 4000, + text.as_bytes(), + ), + ); + let content_revision = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .content_seq(); + + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "a".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision, + previous: None, + }, + ); + let success: SuccessResponse = serde_json::from_str(&response).unwrap(); + let ResponseResult::PaneCopySearch { matches, total, .. } = success.result else { + panic!("expected copy search response"); + }; + assert_eq!(total, 1500); + assert_eq!(matches.len(), 1024); + } + + #[tokio::test] + async fn api_copy_search_rejects_stale_content_revision() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_scrollback_bytes( + 20, + 5, + 1000, + b"alpha beta", + ), + ); + let response = app.handle_pane_copy_search( + "req".into(), + PaneCopySearchParams { + pane_id: public_pane_id, + query: "alpha".into(), + direction: PaneCopySearchDirection::Forward, + cursor: PaneTextPoint { row: 0, col: 0 }, + content_revision: 2, + previous: None, + }, + ); + assert!(response.contains("stale_content")); + } + + #[tokio::test] + async fn api_edit_scrollback_rejects_a_pane_that_is_no_longer_focused() { + let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); + app.state.active = None; + + let response = app.handle_pane_edit_scrollback( + "req".into(), + PaneTarget { + pane_id: public_pane_id, + }, + ); + + assert_eq!(metadata_error_code(&response), "stale_pane_target"); + assert!(app.overlay_panes.is_empty()); + } + + #[tokio::test] async fn api_pane_read_reports_when_older_rows_are_omitted() { let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); @@ -2255,7 +2821,7 @@ mod tests { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), - true, + crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), @@ -2305,7 +2871,6 @@ mod tests { let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); - assert_eq!(app.state.request_remove_linked_worktree, None); assert!(app.state.workspaces.is_empty()); } @@ -2415,7 +2980,11 @@ mod tests { let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); @@ -2443,45 +3012,15 @@ mod tests { } #[test] - fn api_pane_swap_unfocused_source_updates_last_pane_history() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let focused = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Vertical); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(focused); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - - let response = app.handle_pane_swap( - "req".into(), - PaneSwapParams { - source_pane_id: Some(source_public), - target_pane_id: Some(target_public), - ..PaneSwapParams::default() - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneSwap { swap } = success.result else { - panic!("expected pane swap response"); - }; - assert!(swap.changed); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - - app.state.last_pane(); - - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(focused)); - } - - #[test] fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(source); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( @@ -2637,124 +3176,6 @@ mod tests { Some(&source_terminal) ); } - - #[test] - fn api_pane_move_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); - let target = app.state.workspaces[0].tabs[target_tab].root_pane; - seed_terminal_states(&mut app); - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - let target_public = app.public_pane_id(0, target).unwrap(); - let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); - - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_public, - target_pane_id: Some(target_public), - split: SplitDirection::Right, - ratio: None, - }, - focus: true, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneMove { move_result } = success.result else { - panic!("expected pane move response"); - }; - assert!(move_result.changed); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.copy_mode.expect("copy mode").pane_id, source); - assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); - } - - #[tokio::test] - async fn key_release_follows_pane_moved_across_workspaces() { - let mut app = app_with_linked_worktree(); - let source = app.state.workspaces[0].tabs[0].root_pane; - let source_terminal_id = app.state.workspaces[0].tabs[0] - .terminal_id(source) - .unwrap() - .clone(); - let (runtime, mut rx) = - crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( - 80, - 24, - 0, - b"\x1b[>15u", - 2, - ); - app.terminal_runtimes.insert(source_terminal_id, runtime); - app.state.workspaces.push(Workspace::test_new("other")); - app.state.active = Some(0); - app.state.selected = 0; - app.state.mode = Mode::Terminal; - let source_public = app.public_pane_id(0, source).unwrap(); - let target = app.state.workspaces[1].tabs[0].root_pane; - let target_tab_id = app.public_tab_id(1, 0).unwrap(); - let target_pane_id = app.public_pane_id(1, target).unwrap(); - - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ), - )], - false, - ); - let response = app.handle_pane_move( - "req".into(), - PaneMoveParams { - pane_id: source_public, - destination: PaneMoveDestination::Tab { - tab_id: target_tab_id, - target_pane_id: Some(target_pane_id), - split: SplitDirection::Down, - ratio: None, - }, - focus: false, - }, - ); - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - assert!(matches!(success.result, ResponseResult::PaneMove { .. })); - app.route_client_events_from( - 42, - vec![crate::raw_input::RawInputEvent::Key( - crate::input::TerminalKey::new( - crossterm::event::KeyCode::Char('j'), - crossterm::event::KeyModifiers::empty(), - ) - .with_kind(crossterm::event::KeyEventKind::Release), - )], - false, - ); - - assert_eq!( - rx.try_recv().expect("forwarded press"), - bytes::Bytes::from_static(b"\x1b[106;1:1u") - ); - assert_eq!( - rx.try_recv().expect("forwarded release after pane move"), - bytes::Bytes::from_static(b"\x1b[106;1:3u") - ); - assert!(app.input_leases.is_empty()); - } - #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); @@ -3316,86 +3737,6 @@ mod tests { } #[test] - fn api_pane_zoom_explicit_background_pane_updates_focus_history() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let first = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.active = Some(0); - app.state.selected = 0; - app.state.workspaces[0].tabs[0].layout.focus_pane(first); - let target_public = app.public_pane_id(1, target).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(target_public.clone()), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.changed); - assert!(zoom.zoom_changed); - assert!(zoom.focus_changed); - assert_eq!(zoom.pane_id, target_public); - assert_eq!(app.state.active, Some(1)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - assert!(app.state.workspaces[1].tabs[0].zoomed); - - app.state.last_pane(); - - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(first)); - } - - #[test] - fn api_pane_zoom_focuses_copy_mode_pane_back_into_copy_mode() { - let mut app = app_with_linked_worktree(); - app.state.workspaces.push(Workspace::test_new("other")); - let source = app.state.workspaces[0].tabs[0].root_pane; - let target = app.state.workspaces[1].tabs[0].root_pane; - let _other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); - let _target_other = - app.state.workspaces[1].test_split(ratatui::layout::Direction::Horizontal); - app.state.workspaces[1].tabs[0].layout.focus_pane(target); - app.state.active = Some(1); - app.state.selected = 1; - app.state.mode = Mode::Terminal; - app.state.copy_mode = Some(crate::app::state::CopyModeState { - pane_id: source, - cursor_row: 0, - cursor_col: 0, - entry_offset_from_bottom: 0, - selection: None, - search: Default::default(), - }); - let source_public = app.public_pane_id(0, source).unwrap(); - - let response = app.handle_pane_zoom( - "req".into(), - PaneZoomParams { - pane_id: Some(source_public), - mode: PaneZoomMode::On, - }, - ); - - let success: SuccessResponse = serde_json::from_str(&response).unwrap(); - let ResponseResult::PaneZoom { zoom } = success.result else { - panic!("expected pane zoom response"); - }; - assert!(zoom.focus_changed); - assert_eq!(app.state.active, Some(0)); - assert_eq!(app.state.mode, Mode::Copy); - assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); - assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target)); - } - - #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); @@ -3565,7 +3906,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3596,7 +3941,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3623,7 +3972,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_edges( @@ -3650,7 +4003,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(right); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3688,7 +4045,11 @@ mod tests { let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); @@ -3796,7 +4157,11 @@ mod tests { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); - crate::ui::compute_view(&mut app.state, ratatui::layout::Rect::new(0, 0, 100, 20)); + crate::ui::compute_view_with_runtime_registry( + &mut app.state, + &crate::terminal::TerminalRuntimeRegistry::new(), + ratatui::layout::Rect::new(0, 0, 100, 20), + ); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_focus_direction( |
