aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests/text_editing.rs
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/text_editing.rs
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/text_editing.rs')
-rw-r--r--src/client/shell/tests/text_editing.rs395
1 files changed, 395 insertions, 0 deletions
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);
+ }
+ }
+ }
+}