aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests
diff options
context:
space:
mode:
authorMark Jaquith <mark@jaquith.me>2026-09-14 15:47:53 -0400
committerGitHub <noreply@github.com>2026-09-14 21:47:53 +0200
commitb0f21ed2cb85613050b794bf6c78a7c367a94e0d (patch)
tree25c14e09338f5300bedbc8131aa23fab3d37add9 /src/client/shell/tests
parentd8b36691d10c6055293ad45324b22af8b8f570e5 (diff)
feat: add cursor-based editing to text inputs (#3698)
* feat: add cursor-based editing to text inputs refs #1803 * fix: preserve dialog actions with associated text refs #1803 * refactor: simplify text editor outcomes and tests refs #1803 --------- Co-authored-by: JJ Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src/client/shell/tests')
-rw-r--r--src/client/shell/tests/agents_worktrees_notifications.rs2
-rw-r--r--src/client/shell/tests/copy.rs11
-rw-r--r--src/client/shell/tests/graphics.rs8
-rw-r--r--src/client/shell/tests/input.rs16
-rw-r--r--src/client/shell/tests/mod.rs1
-rw-r--r--src/client/shell/tests/popup_focus_projection.rs15
-rw-r--r--src/client/shell/tests/text_editing.rs395
-rw-r--r--src/client/shell/tests/workspace_navigation.rs5
8 files changed, 428 insertions, 25 deletions
diff --git a/src/client/shell/tests/agents_worktrees_notifications.rs b/src/client/shell/tests/agents_worktrees_notifications.rs
index c89991b1..5b8b1224 100644
--- a/src/client/shell/tests/agents_worktrees_notifications.rs
+++ b/src/client/shell/tests/agents_worktrees_notifications.rs
@@ -917,7 +917,7 @@ fn named_workspace_overlay_targets_projected_source_workspace() {
..
},
..
- })) if value == "repo" && source_workspace_id.as_deref() == Some("ws_1")
+ })) if value.as_str() == "repo" && source_workspace_id.as_deref() == Some("ws_1")
));
let create = state.handle_input_bytes(b"\r");
let [ClientShellAction::Endpoint { request, .. }] = &create.actions[..] else {
diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs
index c83f7fc6..8ee35f45 100644
--- a/src/client/shell/tests/copy.rs
+++ b/src/client/shell/tests/copy.rs
@@ -1,10 +1,10 @@
use super::*;
#[test]
-fn pasted_help_and_copy_queries_strip_control_characters() {
+fn pasted_help_and_copy_queries_normalize_single_line_text() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: true,
scroll: 0,
}));
@@ -13,10 +13,11 @@ fn pasted_help_and_copy_queries_strip_control_characters() {
assert!(matches!(
state.overlay,
Some(ClientShellOverlay::Help(ClientHelpOverlay { ref query, .. }))
- if query == "workspace"
+ if query.as_str() == "work space"
));
state.overlay = None;
+ state.mode = ClientShellMode::Copy;
state.copy_mode = Some(ClientCopyModeState {
pane_id: "pane_1".into(),
content_revision: 0,
@@ -28,7 +29,7 @@ fn pasted_help_and_copy_queries_strip_control_characters() {
selection: None,
search_prompt: Some(ClientCopySearchPrompt {
direction: crate::api::schema::PaneCopySearchDirection::Forward,
- query: String::new(),
+ query: TextEditor::default(),
}),
search_query: String::new(),
search_direction: None,
@@ -47,7 +48,7 @@ fn pasted_help_and_copy_queries_strip_control_characters() {
.as_ref()
.and_then(|copy_mode| copy_mode.search_prompt.as_ref())
.map(|prompt| prompt.query.as_str()),
- Some("needle")
+ Some("needle ")
);
}
diff --git a/src/client/shell/tests/graphics.rs b/src/client/shell/tests/graphics.rs
index 1b0071f1..c5fa1f4a 100644
--- a/src/client/shell/tests/graphics.rs
+++ b/src/client/shell/tests/graphics.rs
@@ -210,7 +210,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename",
input: "name".into(),
- replace_on_type: false,
target: ClientRenameTarget::Pane {
pane_id: "pane_1".into(),
},
@@ -221,12 +220,12 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
detail: "confirm".into(),
}),
ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
scroll: 0,
}),
ClientShellOverlay::Navigator(ClientNavigatorOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
selected: None,
scroll: 0,
@@ -238,7 +237,6 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
repo_name: "repo".into(),
branch: "branch".into(),
checkout_path: "path".into(),
- replace_on_type: false,
error: None,
creating: false,
}),
@@ -246,7 +244,7 @@ fn every_dialog_and_menu_occludes_its_panel_not_the_whole_screen() {
source_workspace_id: "ws_1".into(),
entries: Vec::new(),
selected: 0,
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
error: None,
opening: false,
diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs
index 35916ecf..b55cf9a2 100644
--- a/src/client/shell/tests/input.rs
+++ b/src/client/shell/tests/input.rs
@@ -107,6 +107,15 @@ fn full_host_palette_response_is_sent_as_one_theme_update() {
#[test]
fn modal_paste_shortcut_modifiers_are_platform_specific() {
let key = |code, modifiers| crate::input::TerminalKey::new(code, modifiers);
+ for macos in [false, true] {
+ assert!(!input::is_modal_paste_shortcut_for_platform(
+ &key(
+ KeyCode::Char('v'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ ),
+ macos
+ ));
+ }
assert!(input::is_modal_paste_shortcut_for_platform(
&key(KeyCode::Char('v'), KeyModifiers::CONTROL),
@@ -142,8 +151,7 @@ fn modal_paste_inserts_clipboard_text_through_overlay_text_path() {
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename pane",
- input: "replace me".into(),
- replace_on_type: true,
+ input: TextEditor::new("replace me", true),
target: ClientRenameTarget::Pane {
pane_id: "pane_1".into(),
},
@@ -159,8 +167,8 @@ fn modal_paste_inserts_clipboard_text_through_overlay_text_path() {
assert!(outcome.repaint);
assert!(matches!(
state.overlay,
- Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, replace_on_type: false, .. }))
- if input == "feature/pasted"
+ Some(ClientShellOverlay::Rename(ClientRenameOverlay { ref input, .. }))
+ if input.as_str() == "feature/pasted"
));
}
diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs
index b6b600bc..787d8a75 100644
--- a/src/client/shell/tests/mod.rs
+++ b/src/client/shell/tests/mod.rs
@@ -5,6 +5,7 @@ use crate::protocol::{
PaneSurfaceSplit, PaneSurfaceSplitDirection, SurfaceRect,
};
use crossterm::event::MouseEvent;
+mod text_editing;
pub(super) fn snapshot() -> ClientShellSnapshot {
ClientShellSnapshot {
diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs
index 044ef145..aa44bf6d 100644
--- a/src/client/shell/tests/popup_focus_projection.rs
+++ b/src/client/shell/tests/popup_focus_projection.rs
@@ -37,8 +37,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
state.overlay = Some(ClientShellOverlay::Rename(ClientRenameOverlay {
title: "rename pane",
- input: String::new(),
- replace_on_type: false,
+ input: TextEditor::default(),
target: ClientRenameTarget::Pane {
pane_id: "pane_1".into(),
},
@@ -49,9 +48,8 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
ClientWorktreeCreateOverlay {
source_workspace_id: "ws_1".into(),
repo_name: "repo".into(),
- branch: String::new(),
+ branch: TextEditor::default(),
checkout_path: String::new(),
- replace_on_type: false,
error: None,
creating: true,
},
@@ -67,7 +65,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
source_workspace_id: "ws_1".into(),
entries: Vec::new(),
selected: 0,
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
error: None,
opening: false,
@@ -80,7 +78,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
assert!(state.modal_paste_target_active());
state.overlay = Some(ClientShellOverlay::Navigator(ClientNavigatorOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
selected: None,
scroll: 0,
@@ -94,7 +92,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
assert!(state.modal_paste_target_active());
state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
- query: String::new(),
+ query: TextEditor::default(),
search_focused: false,
scroll: 0,
}));
@@ -116,7 +114,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
selection: None,
search_prompt: Some(ClientCopySearchPrompt {
direction: crate::api::schema::PaneCopySearchDirection::Forward,
- query: String::new(),
+ query: TextEditor::default(),
}),
search_query: String::new(),
search_direction: None,
@@ -127,6 +125,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() {
search_generation: 0,
copy_after_search: false,
});
+ state.mode = ClientShellMode::Copy;
assert!(state.modal_paste_target_active());
state.popup_pending = true;
assert!(!state.modal_paste_target_active());
diff --git a/src/client/shell/tests/text_editing.rs b/src/client/shell/tests/text_editing.rs
new file mode 100644
index 00000000..b63ce9f6
--- /dev/null
+++ b/src/client/shell/tests/text_editing.rs
@@ -0,0 +1,395 @@
+use super::*;
+use crate::input::{KeybindAction, KeybindMatch, TerminalKey, TextCommit};
+
+fn shell(field: usize) -> ClientShellState {
+ let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
+ state.set_snapshot(Box::new(snapshot()));
+ let mut frame = surface();
+ frame.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics {
+ offset_from_bottom: 0,
+ max_offset_from_bottom: 0,
+ viewport_rows: 2,
+ });
+ state.set_pane_surface(frame);
+ state.compose(106, 30).expect("initial shell");
+ match field {
+ 0 => state.open_new_workspace_overlay(),
+ 1 => state.open_rename_workspace_overlay(),
+ 2 => state.open_new_tab_overlay(),
+ 3 => state.open_rename_tab_overlay(),
+ 4 => state.open_rename_pane_overlay(),
+ 5 => {
+ state.handle_worktree_endpoint_result(
+ PendingEndpointKind::PrepareWorktreeCreate {
+ workspace_id: "ws_1".into(),
+ },
+ Ok(worktree_list_result(None)),
+ &mut ClientShellInput::default(),
+ );
+ }
+ 6 => {
+ state.open_navigator_overlay();
+ state.handle_input_bytes(b"/");
+ }
+ 7 => {
+ state.overlay = Some(ClientShellOverlay::Help(ClientHelpOverlay {
+ query: TextEditor::default(),
+ search_focused: true,
+ scroll: 0,
+ }))
+ }
+ 8 => {
+ state.handle_worktree_endpoint_result(
+ PendingEndpointKind::PrepareWorktreeOpen {
+ workspace_id: "ws_1".into(),
+ },
+ Ok(worktree_list_result(None)),
+ &mut ClientShellInput::default(),
+ );
+ state.handle_input_bytes(b"/");
+ }
+ 9 => {
+ state.record_binding(
+ KeybindMatch::Action(KeybindAction::CopyMode),
+ &mut ClientShellInput::default(),
+ );
+ state.handle_input_bytes(b"/");
+ }
+ _ => unreachable!(),
+ }
+ state
+}
+
+fn editor(state: &mut ClientShellState) -> &mut TextEditor {
+ match state.overlay.as_mut() {
+ Some(ClientShellOverlay::Rename(v)) => &mut v.input,
+ Some(ClientShellOverlay::Navigator(v)) => &mut v.query,
+ Some(ClientShellOverlay::Help(v)) => &mut v.query,
+ Some(ClientShellOverlay::WorktreeCreate(v)) => &mut v.branch,
+ Some(ClientShellOverlay::WorktreeOpen(v)) => &mut v.query,
+ _ => {
+ &mut state
+ .copy_mode
+ .as_mut()
+ .expect("copy mode")
+ .search_prompt
+ .as_mut()
+ .expect("prompt")
+ .query
+ }
+ }
+}
+
+fn press(state: &mut ClientShellState, code: KeyCode, modifiers: KeyModifiers) -> ClientShellInput {
+ state.handle_raw_events(vec![RawInputEvent::Key(TerminalKey::new(code, modifiers))])
+}
+
+#[test]
+fn all_ten_fields_route_shared_text_editing() {
+ for field in 0..10 {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("ab");
+ press(&mut state, KeyCode::Left, KeyModifiers::NONE);
+ let result = press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE);
+ assert!(result.repaint, "field {field}");
+ assert!(result.requests.is_empty() && result.actions.is_empty());
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ }
+}
+
+#[test]
+fn text_delivery_paths_insert_at_the_cursor() {
+ for delivery in 0..4 {
+ let mut state = shell(0);
+ *editor(&mut state) = TextEditor::from("ab");
+ press(&mut state, KeyCode::Left, KeyModifiers::NONE);
+ let result = match delivery {
+ 0 => state.handle_raw_events(vec![RawInputEvent::Key(
+ TerminalKey::new(KeyCode::Char('x'), KeyModifiers::NONE)
+ .with_generated_text(Some("X".into())),
+ )]),
+ 1 => state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]),
+ 2 => state.handle_raw_events(vec![RawInputEvent::Paste("X".into())]),
+ _ => {
+ let mut result = ClientShellInput::default();
+ assert!(state.handle_modal_paste_shortcut_with(
+ &TerminalKey::new(KeyCode::Char('v'), KeyModifiers::CONTROL),
+ &mut result,
+ || Some("X".into())
+ ));
+ result
+ }
+ };
+ assert!(result.repaint, "delivery {delivery}");
+ assert!(result.requests.is_empty() && result.actions.is_empty());
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ }
+}
+
+#[test]
+fn rename_clear_exceptions_remain_local_and_copy_prompt_yields_to_popup() {
+ for field in 0..5 {
+ for (code, modifiers) in [
+ (KeyCode::Char('c'), KeyModifiers::CONTROL),
+ (KeyCode::Backspace, KeyModifiers::SUPER),
+ ] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("name");
+ press(&mut state, code, modifiers);
+ assert!(state.overlay.is_some());
+ assert!(editor(&mut state).is_empty());
+ }
+ }
+ let mut state = shell(9);
+ *editor(&mut state) = TextEditor::from("query");
+ state.popup_terminal_id = Some("popup-test".into());
+ let result = state.handle_raw_events(vec![
+ RawInputEvent::Text(TextCommit::new("text")),
+ RawInputEvent::Paste("paste".into()),
+ ]);
+ assert!(
+ matches!(&result.requests[..], [ClientMessage::ClientShellPopupInput { terminal_id, events }] if terminal_id == "popup-test" && events.len() == 2)
+ );
+ assert_eq!(editor(&mut state).as_str(), "query");
+ assert!(!state.modal_paste_target_active());
+}
+
+#[test]
+fn cursor_movement_preserves_filter_selection_scroll_and_branch_error() {
+ for field in [5, 6, 7, 8] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("ab");
+ match state.overlay.as_mut().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => {
+ v.checkout_path = "sentinel".into();
+ v.error = Some("error".into());
+ }
+ ClientShellOverlay::Navigator(v) => {
+ v.scroll = 3;
+ v.selected = Some(ClientNavigatorTarget::Pane {
+ endpoint_id: ClientEndpointId::Local,
+ pane_id: "pane_1".into(),
+ });
+ }
+ ClientShellOverlay::Help(v) => v.scroll = 3,
+ ClientShellOverlay::WorktreeOpen(v) => v.selected = 3,
+ _ => unreachable!(),
+ }
+ press(&mut state, KeyCode::Home, KeyModifiers::NONE);
+ press(&mut state, KeyCode::Char('u'), KeyModifiers::CONTROL); // Empty kill must not refresh results.
+ match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => {
+ assert_eq!(v.checkout_path, "sentinel");
+ assert_eq!(v.error.as_deref(), Some("error"));
+ }
+ ClientShellOverlay::Navigator(v) => {
+ assert_eq!(v.scroll, 3);
+ assert!(v.selected.is_some());
+ }
+ ClientShellOverlay::Help(v) => assert_eq!(v.scroll, 3),
+ ClientShellOverlay::WorktreeOpen(v) => assert_eq!(v.selected, 3),
+ _ => unreachable!(),
+ }
+ press(&mut state, KeyCode::Char('k'), KeyModifiers::CONTROL);
+ match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => {
+ assert_ne!(v.checkout_path, "sentinel");
+ assert!(v.error.is_none());
+ }
+ ClientShellOverlay::Navigator(v) => assert!(v.selected.is_none()),
+ ClientShellOverlay::Help(v) => assert_eq!(v.scroll, 0),
+ ClientShellOverlay::WorktreeOpen(v) => assert_eq!(v.selected, 0),
+ _ => unreachable!(),
+ }
+ }
+}
+
+#[test]
+fn enter_and_escape_preserve_overlay_actions_with_generated_text() {
+ use crate::api::schema::Method;
+ for field in [5, 7, 8] {
+ for code in [KeyCode::Enter, KeyCode::Esc] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("feature");
+ let before = editor(&mut state).clone();
+ let result = state.handle_raw_events(vec![RawInputEvent::Key(
+ TerminalKey::new(code, KeyModifiers::NONE)
+ .with_generated_text(Some("printable".into())),
+ )]);
+ assert!(result.repaint);
+ assert!(result.requests.is_empty());
+ if code == KeyCode::Enter && field != 7 {
+ assert_eq!(editor(&mut state), &before);
+ let [ClientShellAction::Endpoint { request, .. }] = &result.actions[..] else {
+ panic!("field {field} should submit");
+ };
+ match &request.method {
+ Method::WorktreeCreate(params) if field == 5 => {
+ assert_eq!(params.branch.as_deref(), Some("feature"));
+ }
+ Method::WorktreeOpen(params) if field == 8 => {
+ assert_eq!(params.path.as_deref(), Some("/repo-feature"));
+ }
+ _ => panic!("wrong method for field {field}"),
+ }
+ } else {
+ assert!(result.actions.is_empty());
+ if field == 7 && code == KeyCode::Esc {
+ let Some(ClientShellOverlay::Help(help)) = &state.overlay else {
+ panic!("Escape should leave help open");
+ };
+ assert!(!help.search_focused);
+ assert!(help.query.is_empty());
+ assert_eq!(help.scroll, 0);
+ } else {
+ assert!(state.overlay.is_none(), "field {field}, {code:?}");
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn busy_worktree_inputs_ignore_edits_paste_and_cancel() {
+ for field in [5, 8] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from("ab");
+ match state.overlay.as_mut().expect("overlay") {
+ ClientShellOverlay::WorktreeCreate(v) => v.creating = true,
+ ClientShellOverlay::WorktreeOpen(v) => v.opening = true,
+ _ => unreachable!(),
+ }
+ let before = editor(&mut state).clone();
+ for code in [
+ KeyCode::Left,
+ KeyCode::Backspace,
+ KeyCode::Esc,
+ KeyCode::Enter,
+ ] {
+ press(&mut state, code, KeyModifiers::NONE);
+ }
+ state.handle_raw_events(vec![RawInputEvent::Paste("ignored".into())]);
+ assert_eq!(editor(&mut state), &before);
+ assert!(!state.modal_paste_target_active());
+ }
+}
+
+#[test]
+fn focused_filters_keep_ctrl_n_p_navigation_and_literal_commands() {
+ for field in [6, 7, 8] {
+ let mut state = shell(field);
+ if let Some(ClientShellOverlay::Navigator(navigator)) = state.overlay.as_mut() {
+ navigator.selected = None;
+ }
+ if let Some(ClientShellOverlay::WorktreeOpen(open)) = state.overlay.as_mut() {
+ let mut second = open.entries[0].clone();
+ second.path = "/second".into();
+ open.entries.push(second);
+ }
+ state.compose(106, 30).expect("filter frame");
+ let before = match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::Navigator(v) => format!("{:?}", v.selected),
+ ClientShellOverlay::Help(v) => v.scroll.to_string(),
+ ClientShellOverlay::WorktreeOpen(v) => v.selected.to_string(),
+ _ => unreachable!(),
+ };
+ press(&mut state, KeyCode::Char('n'), KeyModifiers::CONTROL);
+ let after = match state.overlay.as_ref().expect("overlay") {
+ ClientShellOverlay::Navigator(v) => format!("{:?}", v.selected),
+ ClientShellOverlay::Help(v) => v.scroll.to_string(),
+ ClientShellOverlay::WorktreeOpen(v) => v.selected.to_string(),
+ _ => unreachable!(),
+ };
+ assert_ne!(before, after, "field {field}");
+ press(&mut state, KeyCode::Char('p'), KeyModifiers::CONTROL);
+ assert!(editor(&mut state).is_empty());
+ for ch in ['j', 'k', '?'] {
+ press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE);
+ }
+ assert_eq!(editor(&mut state).as_str(), "jk?");
+ }
+}
+
+#[test]
+fn all_naming_targets_preserve_submission_and_empty_semantics() {
+ use crate::api::schema::Method;
+ for field in 0..5 {
+ for empty in [false, true] {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::from(if empty { " " } else { " ab " });
+ if !empty {
+ press(&mut state, KeyCode::Home, KeyModifiers::NONE);
+ press(&mut state, KeyCode::Char('X'), KeyModifiers::NONE);
+ }
+ let result = press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
+ assert!(state.overlay.is_none());
+ if empty && matches!(field, 1 | 3) {
+ assert!(result.actions.is_empty());
+ continue;
+ }
+ let [ClientShellAction::Endpoint { request, .. }] = &result.actions[..] else {
+ panic!("naming target {field}");
+ };
+ let expected = if empty { "" } else { "X ab" };
+ match &request.method {
+ Method::WorkspaceCreate(v) => {
+ assert_eq!(v.label.as_deref(), (!empty).then_some(expected))
+ }
+ Method::WorkspaceRename(v) => assert_eq!(v.label, expected),
+ Method::TabCreate(v) => {
+ assert_eq!(v.label.as_deref(), (!empty).then_some(expected))
+ }
+ Method::TabRename(v) => assert_eq!(v.label, expected),
+ Method::PaneRename(v) => assert_eq!(v.label.as_deref(), Some(expected)),
+ _ => panic!("wrong method"),
+ }
+ }
+ }
+}
+
+#[test]
+fn copy_search_owns_prefix_but_parked_prompt_does_not_steal_input() {
+ let mut state = shell(9);
+ *editor(&mut state) = TextEditor::from("ab");
+ press(&mut state, KeyCode::Char('b'), KeyModifiers::CONTROL);
+ assert_eq!(state.mode, ClientShellMode::Copy);
+ state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("X"))]);
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ state.open_rename_pane_overlay();
+ assert!(state.modal_paste_target_active());
+ state.handle_raw_events(vec![RawInputEvent::Paste("name".into())]);
+ assert_eq!(editor(&mut state).as_str(), "name");
+ state.overlay = None;
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ state.mode = ClientShellMode::Terminal;
+ assert!(!state.modal_paste_target_active());
+ let input = state.handle_raw_events(vec![RawInputEvent::Text(TextCommit::new("terminal"))]);
+ assert!(
+ matches!(&input.requests[..], [ClientMessage::ClientShellPaneInput { events, .. }] if matches!(&events[..], [ClientPaneInputEvent::TextCommit(text)] if text == "terminal"))
+ );
+ assert_eq!(editor(&mut state).as_str(), "aXb");
+ state.mode = ClientShellMode::Copy;
+ press(&mut state, KeyCode::Esc, KeyModifiers::NONE);
+ press(&mut state, KeyCode::Char('b'), KeyModifiers::CONTROL);
+ assert_eq!(state.mode, ClientShellMode::Prefix);
+}
+
+#[test]
+fn every_field_renders_long_unicode_across_resize_without_mutation() {
+ for field in 0..10 {
+ let mut state = shell(field);
+ *editor(&mut state) = TextEditor::new(&"e\u{301}δΈ­πŸ‘©β€πŸ’»".repeat(40), false);
+ for position in [KeyCode::Home, KeyCode::End, KeyCode::Left] {
+ press(&mut state, position, KeyModifiers::NONE);
+ for (width, height) in [(120, 40), (60, 20), (12, 6), (1, 1), (120, 40)] {
+ let before = editor(&mut state).clone();
+ if let Some(frame) = state.compose(width, height) {
+ if let Some(cursor) = frame.cursor.filter(|cursor| cursor.visible) {
+ assert!(cursor.x < width && cursor.y < height, "field {field}");
+ }
+ }
+ assert_eq!(editor(&mut state), &before);
+ }
+ }
+ }
+}
diff --git a/src/client/shell/tests/workspace_navigation.rs b/src/client/shell/tests/workspace_navigation.rs
index 3e19b87c..2a100957 100644
--- a/src/client/shell/tests/workspace_navigation.rs
+++ b/src/client/shell/tests/workspace_navigation.rs
@@ -223,11 +223,12 @@ fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() {
state.set_pane_surface(pane_surface);
state.compose(100, 28).unwrap();
assert!(state.enter_copy_mode(&mut ClientShellInput::default()));
+ enter_navigation(&mut state);
+ // Seed the hidden prompt after navigation: text editing consumes the prefix key.
state.copy_mode.as_mut().unwrap().search_prompt = Some(ClientCopySearchPrompt {
direction: crate::api::schema::PaneCopySearchDirection::Forward,
query: "original".into(),
});
- enter_navigation(&mut state);
preview_key(&mut state, b"\x1b[B");
assert!(state.workspace_preview_action_blocked());
assert!(!state.modal_paste_target_active());
@@ -241,7 +242,7 @@ fn foreign_workspace_preview_blocks_paste_into_hidden_copy_search() {
assert!(paste.actions.is_empty() && paste.requests.is_empty());
assert_eq!(
state.copy_mode.unwrap().search_prompt.unwrap().query,
- "original"
+ "original".into()
);
}