aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/sequence/src
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2026-02-17 15:43:28 +0100
committerGitHub <noreply@github.com>2026-02-17 15:43:28 +0100
commit4e66a0beff5e41bda1dbde521fa1d2858c46fe7b (patch)
tree380bee5eeac56bea2ebf341cccbe69615585e394 /default-plugins/sequence/src
parentFix startup message and cache file creation (#4705) (diff)
downloadsl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.tar.gz
sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.tar.xz
sl0ppij-4e66a0beff5e41bda1dbde521fa1d2858c46fe7b.zip
Revamp the sequence plugin (#4713)
* initial pipe interface * initial open_x_pane_in_new_tab plugin api implementation * fix: send CommandPaneExited for command panes opened as initial_terminals in new tabs from plugins * open panes in place of specific pane id plugin commands - initial implementation * sequence and plugin api ux * focus self when starting * combine ui and pipe paths * highlight selected pane * initial sequence removal of editing capabilities * fix: translate plugin paths in api * properly trim commands when loaded from pipe or pasted text * minor ux fixes * get ctrl c to work properly * behavior fixes * more ui fixes * fix tests * close tab and end sequence properly * default to AND * zellij sequence alias * add alias * consolidate paths * rustfmt * fix ui padding * add pr * fix: reset scrolling when resetting terminal state
Diffstat (limited to 'default-plugins/sequence/src')
-rw-r--r--default-plugins/sequence/src/main.rs1425
-rw-r--r--default-plugins/sequence/src/state/command_entry.rs10
-rw-r--r--default-plugins/sequence/src/state/command_parser.rs177
-rw-r--r--default-plugins/sequence/src/state/editing.rs80
-rw-r--r--default-plugins/sequence/src/state/execution.rs185
-rw-r--r--default-plugins/sequence/src/state/layout.rs54
-rw-r--r--default-plugins/sequence/src/state/mod.rs461
-rw-r--r--default-plugins/sequence/src/state/positioning.rs49
-rw-r--r--default-plugins/sequence/src/state/selection.rs175
-rw-r--r--default-plugins/sequence/src/state/sequence_mode.rs6
-rw-r--r--default-plugins/sequence/src/ui/components.rs115
-rw-r--r--default-plugins/sequence/src/ui/fuzzy_complete.rs321
-rw-r--r--default-plugins/sequence/src/ui/mod.rs2
-rw-r--r--default-plugins/sequence/src/ui/text_input.rs997
14 files changed, 830 insertions, 3227 deletions
diff --git a/default-plugins/sequence/src/main.rs b/default-plugins/sequence/src/main.rs
index 0bfbfddc..9c99ac5d 100644
--- a/default-plugins/sequence/src/main.rs
+++ b/default-plugins/sequence/src/main.rs
@@ -4,36 +4,29 @@ mod ui;
use crate::state::CommandStatus;
use crate::ui::components;
-use crate::ui::fuzzy_complete;
use crate::ui::layout_calculations::calculate_viewport;
-use crate::ui::text_input::InputAction;
-use crate::ui::truncation::truncate_middle;
-use state::State;
-use zellij_tile::prelude::actions::Action;
+use state::{parse_commands, ChainType, CommandEntry, SequenceMode, State};
use zellij_tile::prelude::*;
use std::collections::BTreeMap;
use std::path::PathBuf;
-use unicode_width::UnicodeWidthStr;
-
register_plugin!(State);
impl ZellijPlugin for State {
fn load(&mut self, _configuration: BTreeMap<String, String>) {
subscribe(&[
EventType::ModeUpdate,
- EventType::SessionUpdate,
EventType::Key,
EventType::PermissionRequestResult,
EventType::HostFolderChanged,
- EventType::RunCommandResult,
- EventType::ActionComplete,
- EventType::PaneUpdate,
+ EventType::CommandPaneExited,
+ EventType::CommandPaneOpened,
+ EventType::EditPaneExited,
EventType::Timer,
EventType::PastedText,
EventType::TabUpdate,
- EventType::CommandPaneOpened,
+ EventType::BeforeClose,
]);
// Store our own plugin ID and client ID
@@ -41,6 +34,7 @@ impl ZellijPlugin for State {
self.plugin_id = Some(plugin_ids.plugin_id);
self.client_id = Some(plugin_ids.client_id);
self.cwd = Some(plugin_ids.initial_cwd);
+ rename_plugin_pane(plugin_ids.plugin_id, "Sequence");
update_title(self);
}
@@ -48,15 +42,67 @@ impl ZellijPlugin for State {
handle_event(self, event)
}
+ #[cfg(target_family = "wasm")]
+ fn pipe(&mut self, pipe_message: PipeMessage) -> bool {
+ if !pipe_message.is_private {
+ return false;
+ }
+
+ let Some(payload) = pipe_message.payload else {
+ return false;
+ };
+
+ let payload = payload.trim().to_string();
+ if payload.is_empty() {
+ return false;
+ }
+
+ // If already running, emit error
+ if self.execution.is_running {
+ if let PipeSource::Cli(pipe_id) = &pipe_message.source {
+ #[cfg(target_family = "wasm")]
+ cli_pipe_output(pipe_id, "error: sequence already running\n");
+ }
+ return false;
+ }
+
+ self.load_commands(&payload, None);
+
+ // Handle blocking: keep CLI alive until sequence finishes
+ if pipe_message
+ .args
+ .get("blocking")
+ .map(|v| v == "true")
+ .unwrap_or(false)
+ {
+ if let PipeSource::Cli(pipe_id) = &pipe_message.source {
+ block_cli_pipe_input(pipe_id);
+ self.blocking_pipe_id = Some(pipe_id.clone());
+ }
+ }
+
+ show_self(true);
+
+ true
+ }
+
fn render(&mut self, rows: usize, cols: usize) {
- // Store dimensions for use in cursor calculations
+ // Store dimensions for use in calculations
self.own_rows = Some(rows);
self.own_columns = Some(cols);
- let max_visible_rows = rows.saturating_sub(5);
+ // Main screen: plugin just opened, no commands entered yet
+ if self.all_commands_are_pending() && !self.execution.can_run_sequence() {
+ for (text, x, y) in components::render_intro_hint(rows, cols) {
+ print_text_with_coordinates(text, x, y, None, None);
+ }
+ return;
+ }
- let base_x = 1;
- let base_y = 0;
+ let is_staging = self.all_commands_are_pending() && self.execution.can_run_sequence();
+
+ // Reserve: 1 header above + 1 padding-to-controls + 1 controls + 1 below/ribbon
+ let max_visible_rows = rows.saturating_sub(4);
let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport(
self.execution.all_commands.len(),
@@ -65,6 +111,37 @@ impl ZellijPlugin for State {
self.execution.current_running_command_index,
);
+ // For the staging screen, center the content both horizontally and vertically.
+ // Content height: 1 header row + visible_count command rows + 1 gap + 1 help row = visible_count + 3
+ let (base_x, base_y, table_max_width) = if is_staging {
+ let longest_cwd = self.execution.longest_cwd_display(&self.cwd);
+ let longest_cmd = self
+ .execution
+ .all_commands
+ .iter()
+ .map(|c| c.get_text().chars().count())
+ .max()
+ .unwrap_or(1);
+ let (max_chain_w, max_status_w) = components::calculate_max_widths(
+ &self.execution.all_commands,
+ self.layout.spinner_frame,
+ );
+ let content_width = components::calculate_longest_line(
+ &longest_cwd,
+ longest_cmd,
+ max_chain_w,
+ max_status_w,
+ )
+ .min(cols);
+ // header + commands + gap + help + ribbon/below = visible_count + 4
+ let content_height = visible_count + 4;
+ let bx = cols.saturating_sub(content_width) / 2;
+ let by = rows.saturating_sub(content_height) / 2;
+ (bx, by, Some(content_width))
+ } else {
+ (1, 0, self.own_columns.map(|o| o.saturating_sub(1)))
+ };
+
let mut table = components::build_table_header(hidden_above > 0 || hidden_below > 0);
for index in offset..offset + visible_count {
@@ -79,20 +156,45 @@ impl ZellijPlugin for State {
);
}
- print_table_with_coordinates(
- table,
- base_x,
- base_y,
- self.own_columns.map(|o| o.saturating_sub(base_x)),
- None,
- );
+ print_table_with_coordinates(table, base_x, base_y, table_max_width, None);
let help_y = base_y + visible_count + 2;
- let (first_help, _, second_help) = components::render_help_lines(self, Some(cols));
- print_text_with_coordinates(first_help, base_x, help_y, None, None);
+ let (first_help, first_help_len, second_help) =
+ components::render_help_lines(self, Some(cols));
+ let help_x = if is_staging {
+ cols.saturating_sub(first_help_len) / 2
+ } else {
+ base_x
+ };
+ print_text_with_coordinates(first_help, help_x, help_y, None, None);
- if let Some((second_help_text, _)) = second_help {
- print_text_with_coordinates(second_help_text, base_x, help_y + 1, None, None);
+ if let Some((second_help_text, second_help_len)) = second_help {
+ let second_help_x = if is_staging {
+ cols.saturating_sub(second_help_len) / 2
+ } else {
+ base_x
+ };
+ print_text_with_coordinates(second_help_text, second_help_x, help_y + 1, None, None);
+ }
+
+ // Mode ribbon: "<Tab> [Single Pane] [Spread]" — only while running
+ if self.execution.is_running {
+ let ribbon_y = help_y + 1;
+ print_text_with_coordinates(
+ Text::new("<Tab>").color_all(3),
+ base_x,
+ ribbon_y,
+ None,
+ None,
+ );
+ let ribbon_x = base_x + 6; // "<Tab>" (5) + space (1)
+ let (single_ribbon, spread_ribbon) = if self.mode == SequenceMode::SinglePane {
+ (Text::new("Single Pane").selected(), Text::new("Spread"))
+ } else {
+ (Text::new("Single Pane"), Text::new("Spread").selected())
+ };
+ print_ribbon_with_coordinates(single_ribbon, ribbon_x, ribbon_y, Some(15), None);
+ print_ribbon_with_coordinates(spread_ribbon, ribbon_x + 15, ribbon_y, Some(10), None);
}
}
}
@@ -108,789 +210,304 @@ pub fn handle_event(state: &mut State, event: Event) -> bool {
},
Event::ModeUpdate(mode_info) => {
state.shell = mode_info.shell.clone();
-
false
},
Event::Key(key) => {
- let mut should_render = handle_key_event(state, key);
+ let should_render = handle_key_event(state, key);
let repositioned = state.reposition_plugin();
if repositioned {
// we only want to render once we have repositioned, we will do this in TabUpdate
- should_render = false;
+ return false;
}
- update_cursor(state);
+ show_cursor(None);
should_render
},
- Event::SessionUpdate(session_infos, _resurrectable_sessions) => {
- if state.is_first_run {
- // Find the current session
- let current_session = session_infos.iter().find(|s| s.is_current_session);
-
- if let Some(session) = current_session {
- // Get the pane history for this client
- if let Some(client_id) = state.client_id {
- if let Some(pane_history) = session.pane_history.get(&client_id) {
- let own_pane_id = state.plugin_id.map(|id| PaneId::Plugin(id));
- state.primary_pane_id = select_primary_pane_from_history(
- pane_history,
- &session.panes,
- own_pane_id,
- );
- state.original_pane_id = state.primary_pane_id; // pane id focused
- // before plugin
- // launched
- state.is_first_run = false;
- }
- }
- }
- }
- false
- },
Event::Timer(_elapsed) => {
- // Timer events are used for lock backoff
-
let should_render = update_spinner(state);
-
+ update_title(state);
should_render
},
Event::PastedText(pasted_text) => {
- // Split pasted text into lines
- let mut should_render = true;
- let lines: Vec<&str> = pasted_text
- .lines()
- .map(|line| line.trim())
- .filter(|line| !line.is_empty())
- .collect();
-
- if lines.is_empty() {
+ if state.execution.is_running {
return false;
}
- state.pasted_lines(lines);
- let repositioned = state.reposition_plugin();
- if repositioned {
- // we only want to render once we have repositioned, we will do this in TabUpdate
- should_render = false;
+ let payload = pasted_text.trim().to_string();
+ if payload.is_empty() {
+ return false;
}
- update_cursor(state);
-
- should_render
+ state.load_commands(&payload, None);
+ let repositioned = state.reposition_plugin();
+ !repositioned
},
Event::TabUpdate(tab_infos) => {
if let Some(tab_info) = tab_infos.iter().find(|t| t.active) {
- let new_cols = Some(tab_info.viewport_columns);
- let new_rows = Some(tab_info.viewport_rows);
-
- // Check if dimensions changed
- let dimensions_changed = new_cols != state.total_viewport_columns
- || new_rows != state.total_viewport_rows;
-
- state.total_viewport_columns = new_cols;
- state.total_viewport_rows = new_rows;
-
- if dimensions_changed {
- state.reposition_plugin();
- }
- update_cursor(state);
+ state.total_viewport_columns = Some(tab_info.viewport_columns);
+ state.total_viewport_rows = Some(tab_info.viewport_rows);
}
-
- false
+ state.reposition_plugin()
},
- Event::PaneUpdate(pane_manifest) => handle_pane_update(state, pane_manifest),
- Event::ActionComplete(_action, pane_id, context) => {
- let should_render = handle_action_complete(state, pane_id, context);
+ Event::CommandPaneExited(terminal_pane_id, exit_code, _context) => {
+ let should_render = handle_command_pane_exited(state, terminal_pane_id, exit_code);
+ let repositioned = state.reposition_plugin();
update_title(state);
- should_render
+ should_render || repositioned
},
- Event::CommandPaneOpened(terminal_pane_id, context) => {
- // we get this event immediately as the pane opens, we use it to associate the
- // pane's id with our state
-
- if !is_running_sequence(&context, state.execution.sequence_id) {
- // action from previous sequence or unrelated
- return false;
- }
- if let Some(command_text) = context.get("command_text") {
- state.update_pane_id_for_command(PaneId::Terminal(terminal_pane_id), command_text);
- if !state.layout.spinner_timer_scheduled {
- set_timeout(0.1);
- state.layout.spinner_timer_scheduled = true;
- }
- update_title(state);
+ Event::CommandPaneOpened(_terminal_pane_id, _context) => {
+ // Schedule spinner on first pane open
+ if !state.layout.spinner_timer_scheduled {
+ set_timeout(0.1);
+ state.layout.spinner_timer_scheduled = true;
}
-
+ update_title(state);
true
},
+ Event::EditPaneExited(terminal_pane_id, _exit_code, _context) => {
+ let should_render = handle_editor_pane_exited(state, terminal_pane_id);
+ let repositioned = state.reposition_plugin();
+ should_render || repositioned
+ },
+ Event::BeforeClose => {
+ if let Some(pipe_id) = state.blocking_pipe_id.take() {
+ unblock_cli_pipe_input(&pipe_id);
+ }
+ false
+ },
_ => false,
}
}
-fn handle_key_event(state: &mut State, key: KeyWithModifier) -> bool {
- // Ctrl+Enter - Insert command after current with AND chain type
- if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Enter) {
- let mut is_cd = false;
- if let Some(current_text) = state.editing_input_text() {
- if let Some(path) = state::detect_cd_command(&current_text) {
- if let Some(new_cwd) = path_formatting::resolve_path(state.cwd.as_ref(), &path) {
- state.current_selected_command_mut().map(|c| {
- c.set_cwd(Some(new_cwd));
- c.set_text("".to_owned());
- });
- state.editing.editing_input.as_mut().map(|i| i.clear());
- is_cd = true
- }
- }
- };
-
- state.add_empty_command_after_current_selected();
- if is_cd {
- state.start_editing_selected();
- }
- return true;
- }
-
- // Ctrl+X - Cycle chain type
- if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('x')) {
- // return state.ui.command_sequence.cycle_current_chain_type();
- state.cycle_chain_type();
- return true;
- }
+fn handle_command_pane_exited(
+ state: &mut State,
+ terminal_pane_id: u32,
+ exit_code: Option<i32>,
+) -> bool {
+ let pane_id = PaneId::Terminal(terminal_pane_id);
- // Ctrl+Space, copy to clipboard
- if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char(' ')) {
- state.copy_to_clipboard();
+ // Find which command this pane belongs to — stale events produce no match
+ let Some(cmd_index) =
+ state.execution.all_commands.iter().position(
+ |c| matches!(c.get_status(), CommandStatus::Running(Some(id)) if id == pane_id),
+ )
+ else {
return false;
- }
-
- // Ctrl+w, close all panes and clear sequence
- if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('w')) {
- if !state.execution.is_running && !state.all_commands_are_pending() {
- close_panes_and_return_to_shell(state);
- }
- return true;
- }
-
- if matches!(key.bare_key, BareKey::Up)
- && !key.has_modifiers(&[KeyModifier::Ctrl])
- && !key.has_modifiers(&[KeyModifier::Alt])
- {
- state.move_selection_up();
- return true;
- }
-
- if matches!(key.bare_key, BareKey::Down)
- && !key.has_modifiers(&[KeyModifier::Ctrl])
- && !key.has_modifiers(&[KeyModifier::Alt])
- {
- state.move_selection_down();
- return true;
- }
-
- // Del - delete current command
- if matches!(key.bare_key, BareKey::Delete)
- && !key.has_modifiers(&[KeyModifier::Ctrl])
- && !key.has_modifiers(&[KeyModifier::Alt])
- {
- state.remove_current_selected_command();
- return true;
- }
-
- // Ctrl+C - Clear currently focused command, or remove it if already empty
- if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('c')) {
- if state.execution.is_running {
- interrupt_sequence(state);
- return true;
- } else if state.editing.editing_input.is_some() {
- let is_empty = state.current_selected_command_is_empty();
- let has_more_than_one_command = state.execution.all_commands.len() > 1;
+ };
- if is_empty && has_more_than_one_command {
- state.remove_current_selected_command();
- } else if is_empty {
- // last command, return to shell
- close_panes_and_return_to_shell(state);
- } else {
- // If not empty, clear it
- state.clear_current_selected_command();
- }
- return true;
- }
- }
+ state.execution.all_commands[cmd_index]
+ .set_status(CommandStatus::Exited(exit_code, Some(pane_id)));
- if state.editing.editing_input.is_none()
- && key.has_no_modifiers()
- && matches!(key.bare_key, BareKey::Char('e'))
- {
- state.start_editing_selected();
- return true;
- }
-
- if state.editing.editing_input.is_none()
- && key.has_no_modifiers()
- && matches!(key.bare_key, BareKey::Enter)
- {
- rerun_sequence(state);
+ if !state.execution.is_running {
return true;
}
- // Handle input actions from TextInput
- let is_backspace = matches!(key.bare_key, BareKey::Backspace);
-
- if let Some(action) = state
- .editing
- .editing_input
- .as_mut()
- .map(|i| i.handle_key(key))
- {
- match action {
- InputAction::Submit => {
- return handle_submit(state);
- },
- InputAction::Cancel => {
- state.cancel_editing_selected();
- if state.all_commands_are_pending() {
- // we always want to stay in editing mode in main screen
- state.start_editing_selected();
- }
- true
- },
- InputAction::Complete => {
- state
- .editing_input_text()
- .map(|current_text| {
- if let Some(completed) = fuzzy_complete::fuzzy_complete(
- &current_text,
- &Default::default(), // TODO: get rid of this whoel thing?
- state.cwd.as_ref(),
- ) {
- state.set_editing_input_text(completed);
- true
- } else {
- false
- }
- })
- .unwrap_or(false)
- },
- InputAction::Continue => {
- if let Some(current_text) = state.editing_input_text() {
- if let Some((cmd_text, chain_type)) =
- state::detect_chain_operator_at_end(&current_text)
- {
- let mut should_add_empty_line = true;
- if let Some(path) = state::detect_cd_command(&cmd_text) {
- if let Some(new_cwd) =
- path_formatting::resolve_path(state.cwd.as_ref(), &path)
- {
- let remaining_text =
- state::get_remaining_after_first_segment(&current_text)
- .unwrap_or_else(|| "".to_owned());
- if remaining_text.len() > 0 {
- should_add_empty_line = false;
- }
- state.current_selected_command_mut().map(|c| {
- c.set_cwd(Some(new_cwd));
- c.set_text(remaining_text);
- });
- }
- } else {
- state.current_selected_command_mut().map(|c| {
- c.set_text(cmd_text);
- c.set_chain_type(chain_type);
- });
- };
- state.cancel_editing_selected();
- if should_add_empty_line {
- state.add_empty_command_after_current_selected();
- }
- state.start_editing_selected();
- }
- }
-
- // Handle backspace on empty command
- if is_backspace {
- let is_empty = state.current_selected_command_is_empty()
- || state
- .editing_input_text()
- .map(|i| i.is_empty())
- .unwrap_or(false);
- let has_more_than_one_command = state.execution.all_commands.len() > 1;
-
- if is_empty && has_more_than_one_command {
- let current_index =
- state.selection.current_selected_command_index.unwrap_or(0);
- if current_index > 0 {
- state.remove_current_selected_command();
- return true;
- }
- } else if is_empty {
- // last command
- close_panes_and_return_to_shell(state);
- }
- }
+ let chain_type = state.execution.all_commands[cmd_index].get_chain_type();
+ let should_continue = match chain_type {
+ ChainType::And => exit_code.unwrap_or(0) == 0,
+ ChainType::Or => exit_code.unwrap_or(0) != 0,
+ ChainType::Then => true,
+ ChainType::None => false,
+ };
- true
- },
- InputAction::NoAction => false,
- }
+ let next_index = cmd_index + 1;
+ if should_continue && next_index < state.execution.all_commands.len() {
+ launch_command_at_index(state, next_index, Some(pane_id), false);
} else {
- false
- }
-}
-
-/// Handle Enter key - submit and execute the command sequence
-fn handle_submit(state: &mut State) -> bool {
- let cwd = state.cwd.clone();
- if state.handle_editing_submit(&cwd) {
- // handled the command internally (eg. cd) no need to run sequence
- return true;
- }
-
- if state.execution.all_commands.len() == 1 && state.can_run_sequence() {
- rerun_sequence(state);
- } else if state.execution.all_commands.len() == 1 {
- state.move_selection_down();
- state.start_editing_selected();
+ state.execution.is_running = false;
+ state.selection.current_selected_command_index = None;
+ // Unblock blocking CLI pipe if one is waiting
+ if let Some(pipe_id) = state.blocking_pipe_id.take() {
+ unblock_cli_pipe_input(&pipe_id);
+ }
}
true
}
-pub fn handle_pane_update(state: &mut State, pane_manifest: PaneManifest) -> bool {
- // Store the manifest for later use
- state.pane_manifest = Some(pane_manifest.clone());
- let mut needs_rerender = false;
- if state.update_exited_command_statuses(&pane_manifest) {
- needs_rerender = true;
- }
- if state.update_sequence_stopped_state() {
- needs_rerender = true;
- }
- state.reposition_plugin();
- update_primary_and_original_pane_ids(state, &pane_manifest);
- needs_rerender
-}
+fn handle_editor_pane_exited(state: &mut State, terminal_pane_id: u32) -> bool {
+ let pane_id = PaneId::Terminal(terminal_pane_id);
-pub fn calculate_cursor_position(state: &mut State) -> Option<(usize, usize)> {
- // Get pane dimensions from state (must be set by render)
- let Some(cols) = state.own_columns else {
- eprintln!("Warning: own_columns not set");
- return None;
- };
- let Some(rows) = state.own_rows else {
- eprintln!("Warning: own_rows not set");
- return None;
- };
+ // Ignore events from unrelated panes
+ if state.editor_pane_id != Some(pane_id) {
+ return false;
+ }
- // Only show cursor if in edit mode
- let (text, cursor_pos) = if let Some(text_input) = &state.editing.editing_input {
- (
- text_input.get_text().to_string(),
- text_input.cursor_position(),
- )
+ // Read the temp file contents
+ let contents = if let Some(ref path) = state.editor_temp_file {
+ std::fs::read_to_string(path).unwrap_or_default()
} else {
- return None;
- };
-
- let Some(edit_index) = state.selection.current_selected_command_index else {
- return None;
+ String::new()
};
- // Calculate folder column width using the longest cwd across all commands
- let longest_cwd_display = state.execution.longest_cwd_display(&state.cwd);
- let folder_display = format!("{} >", longest_cwd_display);
- let folder_col_width = folder_display.width().max(1);
-
- let base_x = 1;
- let base_y = 0;
-
- adjust_scroll_offset(state, rows);
-
- let max_visible_rows = state.own_rows.map(|r| r.saturating_sub(5)).unwrap_or(0);
-
- let (offset, visible_count, hidden_above, hidden_below) = calculate_viewport(
- state.execution.all_commands.len(),
- max_visible_rows,
- state.selection.current_selected_command_index,
- state.execution.current_running_command_index,
- );
+ // Delete the temp file
+ if let Some(ref path) = state.editor_temp_file {
+ let _ = std::fs::remove_file(path);
+ }
- let (max_chain_width, max_status_width) =
- components::calculate_max_widths(&state.execution.all_commands, state.layout.spinner_frame);
+ // Clear editor state
+ state.editor_pane_id = None;
+ state.editor_temp_file = None;
- let Some((_, available_cmd_width)) = components::calculate_row_layout_info(
- edit_index,
- offset,
- visible_count,
- hidden_above,
- hidden_below,
- cols,
- folder_col_width,
- max_chain_width,
- max_status_width,
- ) else {
- return None;
- };
+ // Parse file into commands
+ let commands = parse_commands(&contents, state.cwd.clone());
- // Get cursor position in truncated text
- let (_, cursor_in_truncated) = truncate_middle(&text, available_cmd_width, Some(cursor_pos));
- let Some(cursor_in_truncated) = cursor_in_truncated else {
- return None;
- };
-
- // Calculate relative coordinates within the visible table
- let relative_y = edit_index.saturating_sub(offset) + 1; // +1 for header row
- let relative_x = folder_col_width + 1 + cursor_in_truncated;
+ if !commands.is_empty() {
+ state.execution.all_commands = commands;
+ state.execution.current_running_command_index = 0;
+ state.selection.current_selected_command_index = None;
+ state.execution.is_running = false;
+ } else {
+ // Reset to empty pending state
+ state.execution.all_commands = vec![CommandEntry::new("", state.cwd.clone())];
+ state.execution.current_running_command_index = 0;
+ state.selection.current_selected_command_index = None;
+ state.execution.is_running = false;
+ }
- let x = base_x + relative_x;
- let y = base_y + relative_y;
+ show_self(true);
- Some((x, y))
+ true
}
-pub fn handle_action_complete(
+fn launch_command_at_index(
state: &mut State,
- pane_id: Option<PaneId>,
- context: BTreeMap<String, String>,
-) -> bool {
- // Update primary_pane_id to the pane that just completed the action
- // This ensures we always have a valid pane to target for InPlace launches
- // But only if it's not a floating pane
- if let (Some(pane_id), Some(manifest)) = (pane_id, &state.pane_manifest) {
- if !is_pane_floating(pane_id, manifest) {
- state.primary_pane_id = Some(pane_id);
- } else {
- eprintln!("Not setting primary_pane_id to floating pane {:?}", pane_id);
- }
- }
-
- if !is_running_sequence(&context, state.execution.sequence_id) {
- // action from previous sequence or unrelated
- return false;
- }
-
- // If the sequence has been stopped (e.g., via Ctrl+C), don't continue
- if !state.execution.is_running {
- return true; // Re-render to show the stopped state
- }
-
- // Move to the next command
- let next_index = state.execution.current_running_command_index + 1;
-
- if next_index < state.execution.all_commands.len() {
- // Execute the next command
- // let (next_command, next_chain_type) = &state.execution.all_commands[next_index];
- let Some(next_command) = state.execution.all_commands.get(next_index) else {
- // invalid state
- return true;
- };
- let next_chain_type = next_command.get_chain_type();
- let next_command_cwd = next_command.get_cwd();
- let next_command_text = next_command.get_text();
-
- let shell = state
+ index: usize,
+ replace_pane_id: Option<PaneId>,
+ force_visible: bool,
+) {
+ let cmd = &state.execution.all_commands[index];
+ let cmd_text = cmd.get_text().trim().to_string();
+ let command = CommandToRun {
+ path: state
.shell
.clone()
- .unwrap_or_else(|| PathBuf::from("/bin/bash"));
-
- let command = zellij_tile::prelude::actions::RunCommandAction {
- command: shell,
- args: vec!["-ic".to_string(), next_command_text.trim().to_string()],
- cwd: next_command_cwd,
- hold_on_close: true,
- ..Default::default()
- };
-
- // Determine placement based on layout mode
- let placement = NewPanePlacement::Stacked {
- pane_id_to_stack_under: pane_id,
- borderless: Some(false),
- };
-
- // Update status: mark current command as completed
- // Use the pane_id from ActionComplete if the status is still Pending
- let pane_id_to_mark = state
- .execution
- .all_commands
- .get(state.execution.current_running_command_index)
- .and_then(|c| match c.get_status() {
- CommandStatus::Running(pid) => pid,
- CommandStatus::Pending => pane_id, // Use the pane from ActionComplete
- CommandStatus::Exited(_, pid) => pid, // Already marked, keep it
- CommandStatus::Interrupted(pid) => pid, // Already marked, keep it
- });
-
- state
- .execution
- .all_commands
- .get_mut(state.execution.current_running_command_index)
- .map(|c| c.set_status(CommandStatus::Exited(None, pane_id_to_mark)));
- state.execution.current_running_command_index = next_index;
-
- // Determine unblock_condition based on whether this is the last command
- let unblock_condition = if next_index < state.execution.all_commands.len() - 1 {
- // Not the last command - use the chain type's unblock condition
- next_chain_type.to_unblock_condition()
- } else {
- // Last command - use UnblockCondition::OnAnyExit
- Some(UnblockCondition::OnAnyExit)
- };
-
- let action = Action::NewBlockingPane {
- placement,
- command: Some(command),
- pane_name: Some(next_command_text.trim().to_string()),
- unblock_condition,
- near_current_pane: true,
- };
-
- // Pass the sequence ID in the context
- let mut context = BTreeMap::new();
- context.insert(
- "sequence_id".to_string(),
- state.execution.sequence_id.to_string(),
- );
- context.insert("command_text".to_string(), next_command_text.to_string());
-
- // Put the sequence back with updated index
- run_action(action, context);
+ .unwrap_or_else(|| PathBuf::from("/bin/bash")),
+ args: vec!["-ic".to_string(), cmd_text.clone()],
+ cwd: cmd.get_cwd(),
+ };
+ let user_is_watching =
+ replace_pane_id.is_some() && replace_pane_id == state.execution.displayed_pane_id;
+ let new_pane_id = if state.mode == SequenceMode::Spread {
+ open_command_pane_near_plugin(command, BTreeMap::new())
+ } else if user_is_watching {
+ let prev_pane_id = replace_pane_id.unwrap();
+ let pane_id =
+ open_command_pane_in_place_of_pane_id(prev_pane_id, command, false, BTreeMap::new());
+ if let Some(pane_id) = pane_id {
+ state.execution.displayed_pane_id = Some(pane_id);
+ }
+ pane_id
+ } else if force_visible {
+ let (tab_id, pane_id) = open_command_pane_in_new_tab(command, BTreeMap::new());
+ if let Some(pane_id) = pane_id {
+ state.execution.displayed_pane_id = Some(pane_id);
+ }
+ if let (Some(tab_id), Some(plugin_id)) = (tab_id, state.plugin_id) {
+ state.sequence_tab_id = Some(tab_id);
+ break_panes_to_tab_with_id(&[PaneId::Plugin(plugin_id)], tab_id, true);
+ focus_pane_with_id(PaneId::Plugin(plugin_id), false, false);
+ }
+ pane_id
} else {
- // Sequence complete - mark the last command as Exited
-
- let pane_id_to_mark = state
- .execution
- .all_commands
- .get(state.execution.current_running_command_index)
- .and_then(|c| match c.get_status() {
- CommandStatus::Running(pid) => pid,
- CommandStatus::Pending => pane_id, // Use the pane from ActionComplete
- CommandStatus::Exited(_, pid) => pid, // Already marked, keep it
- CommandStatus::Interrupted(pid) => pid, // Already marked, keep it
- });
-
- state
- .execution
- .all_commands
- .get_mut(state.execution.current_running_command_index)
- .map(|c| c.set_status(CommandStatus::Exited(None, pane_id_to_mark)));
+ // User navigated away — open in background, invisible until they navigate to it
+ open_command_pane_background(command, BTreeMap::new())
+ };
+ if let Some(pane_id) = new_pane_id {
+ rename_pane_with_id(pane_id, &cmd_text);
+ state.execution.all_commands[index].set_status(CommandStatus::Running(Some(pane_id)));
+ state.execution.current_running_command_index = index;
+ state.selection.current_selected_command_index = Some(index);
+ state.show_selected_pane();
}
- true
}
-/// Adjust scroll offset to keep selected or running command visible
-pub fn adjust_scroll_offset(sequence: &mut crate::state::State, rows: usize) {
- let max_visible = rows.saturating_sub(6);
- let total_commands = sequence.execution.all_commands.len();
-
- if total_commands <= max_visible {
- sequence.selection.scroll_offset = 0;
- return;
- }
-
- let focus_index = sequence
- .selection
- .current_selected_command_index
- .unwrap_or(sequence.execution.current_running_command_index);
- let half_visible = max_visible / 2;
-
- if focus_index < half_visible {
- sequence.selection.scroll_offset = 0;
- } else if focus_index >= total_commands.saturating_sub(half_visible) {
- sequence.selection.scroll_offset = total_commands.saturating_sub(max_visible);
- } else {
- sequence.selection.scroll_offset = focus_index.saturating_sub(half_visible);
+fn handle_key_event(state: &mut State, key: KeyWithModifier) -> bool {
+ // Ctrl+Space — copy to clipboard
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char(' ')) {
+ state.copy_to_clipboard();
+ return false;
}
- sequence.selection.scroll_offset = sequence
- .selection
- .scroll_offset
- .min(total_commands.saturating_sub(max_visible));
-}
-
-pub fn is_pane_floating(pane_id: PaneId, pane_manifest: &PaneManifest) -> bool {
- pane_manifest
- .panes
- .iter()
- .flat_map(|(_, panes)| panes.iter())
- .find(|pane_info| match pane_id {
- PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
- PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
- })
- .map(|pane_info| pane_info.is_floating)
- .unwrap_or(false)
-}
-
-fn is_running_sequence(context: &BTreeMap<String, String>, running_sequence_id: u64) -> bool {
- if let Some(context_sequence_id) = context.get("sequence_id") {
- if let Ok(context_id) = context_sequence_id.parse::<u64>() {
- if context_id != running_sequence_id {
- // This action is from a different sequence, ignore it
- return false;
- } else {
- return true;
+ // Ctrl+W — close tab if sequence has run; close plugin if staging; no-op if running
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('w')) {
+ if state.all_commands_are_pending() {
+ if let Some(plugin_id) = state.plugin_id {
+ close_pane_with_id(PaneId::Plugin(plugin_id));
}
- } else {
- // Failed to parse sequence_id, ignore this action
- return false;
+ } else if !state.execution.is_running {
+ close_panes_and_return_to_shell(state);
}
- } else {
- return false;
+ return true;
}
-}
-
-fn update_primary_and_original_pane_ids(state: &mut State, pane_manifest: &PaneManifest) {
- if let Some(primary_pane_id) = state.primary_pane_id {
- let mut primary_pane_found = false;
- // Check if the primary pane still exists
- for (_tab_index, panes) in &pane_manifest.panes {
- for pane_info in panes {
- let pane_matches = match primary_pane_id {
- PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
- PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
- };
-
- if pane_matches {
- primary_pane_found = true;
- break;
- }
- }
- if primary_pane_found {
- break;
+ // Ctrl+C — interrupt if running; clear commands if staging; close plugin if empty
+ if key.has_modifiers(&[KeyModifier::Ctrl]) && matches!(key.bare_key, BareKey::Char('c')) {
+ if state.execution.is_running {
+ interrupt_sequence(state);
+ return true;
+ } else if state.all_commands_are_pending() {
+ if state.execution.all_commands.iter().any(|c| !c.is_empty()) {
+ state.clear_all_commands();
+ } else if let Some(plugin_id) = state.plugin_id {
+ close_pane_with_id(PaneId::Plugin(plugin_id));
}
- }
-
- // If primary pane was not found, select a new one
- if !primary_pane_found {
- state.primary_pane_id = None;
+ return true;
}
}
- if let Some(original_pane_id) = state.original_pane_id {
- let mut original_pane_found = false;
- // Check if the original pane still exists
- for (_tab_index, panes) in &pane_manifest.panes {
- for pane_info in panes {
- let pane_matches = match original_pane_id {
- PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
- PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
- };
+ let is_staging = state.all_commands_are_pending() && state.execution.can_run_sequence();
- if pane_matches {
- original_pane_found = true;
- break;
- }
- }
- if original_pane_found {
- break;
- }
- }
-
- // If original pane was not found, select a new one
- if !original_pane_found {
- state.original_pane_id = None;
+ if matches!(key.bare_key, BareKey::Up)
+ && !key.has_modifiers(&[KeyModifier::Ctrl])
+ && !key.has_modifiers(&[KeyModifier::Alt])
+ {
+ if !is_staging {
+ state.move_selection_up();
+ return true;
}
+ return false;
}
-}
-pub fn select_primary_pane_from_history(
- pane_history: &[PaneId],
- pane_manifest: &PaneManifest,
- own_pane_id: Option<PaneId>,
-) -> Option<PaneId> {
- pane_history
- .iter()
- .rev() // Most recent first
- .find(|&id| {
- // Skip self
- if Some(*id) == own_pane_id {
- return false;
- }
-
- // Skip floating panes
- !is_pane_floating(*id, pane_manifest)
- })
- .copied()
-}
-
-fn close_panes_and_return_to_shell(state: &mut State) -> bool {
- // Close all panes in the sequence before clearing it
- // Handle the first pane: replace it with the primary_pane_id_before_sequence if available
- if let Some(first_pane_id) = state.execution.all_commands.iter().find_map(|command| {
- // we look for the first command in the sequence that has a pane that's
- // actually open
- match command.get_status() {
- CommandStatus::Running(pane_id) => pane_id,
- CommandStatus::Exited(_, pane_id) => pane_id,
- CommandStatus::Interrupted(pane_id) => pane_id,
- _ => None,
- }
- }) {
- if let Some(original_pane_id) = state.original_pane_id {
- replace_pane_with_existing_pane(
- first_pane_id,
- original_pane_id,
- true, // suppress_replaced_pane (closes the first pane)
- );
- } else {
- // Fallback: if we don't have a saved primary pane, just close it
- close_pane_with_id(first_pane_id);
+ if matches!(key.bare_key, BareKey::Down)
+ && !key.has_modifiers(&[KeyModifier::Ctrl])
+ && !key.has_modifiers(&[KeyModifier::Alt])
+ {
+ if !is_staging {
+ state.move_selection_down();
+ return true;
}
+ return false;
}
- // Close all other panes (starting from index 1)
- for (idx, command) in state.execution.all_commands.iter().skip(1).enumerate() {
- let pane_id = match command.get_status() {
- CommandStatus::Running(pane_id) => pane_id,
- CommandStatus::Exited(_, pane_id) => pane_id,
- CommandStatus::Interrupted(pane_id) => pane_id,
- _ => None,
- };
+ // e — open external editor (when not running)
+ if !state.execution.is_running
+ && key.has_no_modifiers()
+ && matches!(key.bare_key, BareKey::Char('e'))
+ {
+ state.open_editor();
+ return true;
+ }
- if let Some(pane_id) = pane_id {
- close_pane_with_id(pane_id);
- } else {
- eprintln!(
- "Warning: Cannot close pane at index {}: pane_id is None",
- idx + 1
- );
- }
+ // Enter — rerun sequence
+ if key.has_no_modifiers() && matches!(key.bare_key, BareKey::Enter) {
+ rerun_sequence(state);
+ return true;
}
- state.clear_all_commands();
- // Transition back to shell screen
- if let Some(plugin_id) = state.plugin_id {
- change_floating_pane_coordinates(
- plugin_id,
- Some(25),
- Some(25),
- Some(50),
- Some(50),
- false, // pinned (this should unpin it)
- );
+ // Tab — toggle spread mode (when sequence has run)
+ if key.has_no_modifiers()
+ && matches!(key.bare_key, BareKey::Tab)
+ && !state.all_commands_are_pending()
+ {
+ toggle_spread_mode(state);
+ return true;
}
- update_title(state);
- true
+
+ false
}
-fn change_floating_pane_coordinates(
- own_plugin_id: u32,
- x: Option<usize>,
- y: Option<usize>,
- width: Option<usize>,
- height: Option<usize>,
- should_be_pinned: bool,
-) {
- let coordinates = FloatingPaneCoordinates::new(
- x.map(|x| format!("{}%", x)),
- y.map(|y| format!("{}%", y)),
- width.map(|width| format!("{}%", width)),
- height.map(|height| format!("{}%", height)),
- Some(should_be_pinned),
- Some(false),
- );
- if let Some(coordinates) = coordinates {
- // TODO: better
- // show_self(true);
- change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]);
+fn close_panes_and_return_to_shell(state: &mut State) -> bool {
+ if let Some(tab_id) = state.sequence_tab_id {
+ close_tab_with_id(tab_id as u64);
}
+ true
}
fn update_spinner(state: &mut State) -> bool {
@@ -906,7 +523,7 @@ fn update_spinner(state: &mut State) -> bool {
.all_commands
.iter()
.any(|command| matches!(command.get_status(), CommandStatus::Running(_)));
- if has_running && !state.layout.spinner_timer_scheduled {
+ if has_running {
set_timeout(0.1);
state.layout.spinner_timer_scheduled = true;
}
@@ -930,148 +547,134 @@ fn rerun_sequence(state: &mut State) {
.unwrap_or(0)
.min(state.execution.all_commands.len().saturating_sub(1));
- let mut close_replaced_pane = true;
+ state.execution.is_running = true;
+ state.execution.current_running_command_index = selected_index;
+ ensure_spinner_running(state);
+ match state.execution.all_commands[selected_index].get_pane_id() {
+ Some(PaneId::Terminal(id)) => {
+ let pane_id = PaneId::Terminal(id);
- // Extract the selected pane's ID BEFORE resetting statuses (needed for in-place replacement)
- let selected_pane_id_for_replacement = &state
- .execution
- .all_commands
- .get(selected_index)
- .and_then(|c| match c.get_status() {
- CommandStatus::Running(pane_id) => pane_id,
- CommandStatus::Exited(_, pane_id) => pane_id,
- CommandStatus::Interrupted(pane_id) => pane_id,
- _ => None,
- })
- .or_else(|| {
- if state.all_commands_are_pending() {
- close_replaced_pane = false; // we should never close the original pane_id
- state.original_pane_id
- } else {
- None
+ // Replace the displayed pane with the starting pane first, while
+ // displayed_pane_id is still valid. The replaced pane is suppressed
+ // and can then be closed safely below.
+ if state.mode == SequenceMode::SinglePane {
+ if let Some(displayed) = state.execution.displayed_pane_id {
+ if displayed != pane_id {
+ replace_pane_with_existing_pane(displayed, pane_id, true);
+ }
+ }
+ state.execution.displayed_pane_id = Some(pane_id);
}
- });
-
- // Close all panes AFTER the selected one (selected will be replaced in-place)
- for i in (selected_index + 1)..state.execution.all_commands.len() {
- // Extract pane_id from the command status
- let pane_id = state
- .execution
- .all_commands
- .get(i)
- .and_then(|c| match c.get_status() {
- CommandStatus::Running(pane_id) => pane_id,
- CommandStatus::Exited(_, pane_id) => pane_id,
- CommandStatus::Interrupted(pane_id) => pane_id,
- _ => None,
- });
- // Close the pane if we have a pane_id
- if let Some(pane_id) = pane_id {
- close_pane_with_id(pane_id);
- } else {
- eprintln!(
- "Warning: Cannot close pane for command at index {}: pane_id is None",
- i
- );
- }
+ // Close and reset commands after selected (now suppressed).
+ for i in (selected_index + 1)..state.execution.all_commands.len() {
+ if let Some(p) = state.execution.all_commands[i].get_pane_id() {
+ close_pane_with_id(p);
+ }
+ state.execution.all_commands[i].set_status(CommandStatus::Pending);
+ }
- // Reset the command status to Pending
- state
- .execution
- .all_commands
- .get_mut(i)
- .map(|c| c.set_status(CommandStatus::Pending));
+ state.execution.all_commands[selected_index]
+ .set_status(CommandStatus::Running(Some(pane_id)));
+ state.selection.current_selected_command_index = Some(selected_index);
+ rerun_command_pane(id);
+ },
+ _ => {
+ // Close and reset commands after selected.
+ for i in (selected_index + 1)..state.execution.all_commands.len() {
+ if let Some(p) = state.execution.all_commands[i].get_pane_id() {
+ close_pane_with_id(p);
+ }
+ state.execution.all_commands[i].set_status(CommandStatus::Pending);
+ }
+ let replace_pane_id = state.execution.displayed_pane_id;
+ launch_command_at_index(state, selected_index, replace_pane_id, true);
+ },
}
+}
- // Reset the selected pane's status to Pending (it will be replaced in-place)
- state
- .execution
- .all_commands
- .get_mut(selected_index)
- .map(|c| c.set_status(CommandStatus::Pending));
+fn toggle_spread_mode(state: &mut State) {
+ match state.mode {
+ SequenceMode::SinglePane => enter_spread_mode(state),
+ SequenceMode::Spread => exit_spread_mode(state),
+ }
+}
- // Set the current command index to the selected command
- state.execution.current_running_command_index = selected_index;
+fn enter_spread_mode(state: &mut State) {
+ state.mode = SequenceMode::Spread;
- // Execute the command at the selected index
- if let Some((command_text, chain_type, command_cwd)) = state
+ let first_pane_id = state
.execution
.all_commands
- .get(selected_index)
- .map(|c| (c.get_text(), c.get_chain_type(), c.get_cwd()))
- {
- let shell = state
- .shell
- .clone()
- .unwrap_or_else(|| PathBuf::from("/bin/bash"));
+ .first()
+ .and_then(|c| c.get_pane_id());
+ let Some(first_pane_id) = first_pane_id else {
+ return;
+ };
- let command = zellij_tile::prelude::actions::RunCommandAction {
- command: shell,
- args: vec!["-ic".to_string(), command_text.trim().to_string()],
- cwd: command_cwd,
- hold_on_close: true,
- ..Default::default()
- };
+ if let Some(displayed) = state.execution.displayed_pane_id {
+ if displayed != first_pane_id {
+ replace_pane_with_existing_pane(displayed, first_pane_id, true);
+ }
+ }
+ state.execution.displayed_pane_id = Some(first_pane_id);
- // Determine placement - always replace the selected pane in-place if we have its ID
- let placement = if let Some(pane_id) = selected_pane_id_for_replacement {
- NewPanePlacement::InPlace {
- pane_id_to_replace: Some(*pane_id),
- close_replaced_pane,
- borderless: Some(false),
- }
- } else {
- let pane_id_to_stack_under = state
- .execution
- .all_commands
- .iter()
- .find_map(|c| c.get_pane_id());
- NewPanePlacement::Stacked {
- pane_id_to_stack_under,
- borderless: Some(false),
- }
- };
+ for cmd in state.execution.all_commands.iter().skip(1) {
+ if let Some(pane_id) = cmd.get_pane_id() {
+ show_pane_with_id(pane_id, false, false);
+ }
+ }
+ state.show_selected_pane();
+}
- // Determine unblock_condition based on whether this is the last command
- let unblock_condition = if selected_index < state.execution.all_commands.len() - 1 {
- // Not the last command - use the chain type's unblock condition
- chain_type.to_unblock_condition()
- } else {
- // Last command - use UnblockCondition::OnAnyExit
- Some(UnblockCondition::OnAnyExit)
- };
+fn exit_spread_mode(state: &mut State) {
+ // Remove all highlights before leaving spread mode
+ let all_pane_ids: Vec<PaneId> = state
+ .execution
+ .all_commands
+ .iter()
+ .filter_map(|c| c.get_pane_id())
+ .collect();
+ if !all_pane_ids.is_empty() {
+ highlight_and_unhighlight_panes(vec![], all_pane_ids);
+ }
+ state.mode = SequenceMode::SinglePane;
- let action = Action::NewBlockingPane {
- placement,
- command: Some(command),
- pane_name: Some(command_text.trim().to_string()),
- unblock_condition,
- near_current_pane: true,
- };
+ let first_pane_id = state
+ .execution
+ .all_commands
+ .first()
+ .and_then(|c| c.get_pane_id());
+ let selected_pane_id = state
+ .selection
+ .current_selected_command_index
+ .and_then(|i| state.execution.all_commands.get(i))
+ .and_then(|c| c.get_pane_id())
+ .or(first_pane_id);
- // Generate a new sequence ID for the restart
- state.execution.sequence_id += 1;
+ let Some(selected_pane_id) = selected_pane_id else {
+ return;
+ };
- // Reset the stopped flag so the sequence can run
- state.execution.is_running = true;
- state.layout.needs_reposition = true;
- state.execution.primary_pane_id_before_sequence = state.primary_pane_id;
+ if let Some(first_pane_id) = first_pane_id {
+ if first_pane_id != selected_pane_id {
+ replace_pane_with_existing_pane(first_pane_id, selected_pane_id, true);
+ }
+ }
- // Pass the sequence ID in the context
- let mut context = BTreeMap::new();
- context.insert(
- "sequence_id".to_string(),
- state.execution.sequence_id.to_string(),
- );
- context.insert("command_text".to_string(), command_text.to_string());
- run_action(action, context);
+ for cmd in &state.execution.all_commands {
+ if let Some(pane_id) = cmd.get_pane_id() {
+ if pane_id != selected_pane_id {
+ hide_pane_with_id(pane_id);
+ }
+ }
}
+
+ state.execution.displayed_pane_id = Some(selected_pane_id);
}
fn interrupt_sequence(state: &mut State) {
if state.execution.is_running {
- // Ctrl-C press when running - interrupt the running command
for command in state.execution.all_commands.iter_mut() {
if let CommandStatus::Running(pane_id) = command.get_status() {
if let Some(pane_id) = pane_id {
@@ -1081,59 +684,115 @@ fn interrupt_sequence(state: &mut State) {
}
}
state.execution.is_running = false;
+ state.selection.current_selected_command_index = None;
+ // Unblock blocking CLI pipe if interrupted
+ if let Some(pipe_id) = state.blocking_pipe_id.take() {
+ unblock_cli_pipe_input(&pipe_id);
+ }
} else {
eprintln!("Cannot interrupt a sequence that is not running.");
}
}
-pub fn update_cursor(state: &mut State) {
- // Calculate new cursor position
- let new_coords = calculate_cursor_position(state);
-
- // Only update if the position changed
- if new_coords != state.layout.cached_cursor_position {
- match new_coords {
- Some(coords) => show_cursor(Some(coords)),
- None => show_cursor(None),
- }
- state.layout.cached_cursor_position = new_coords;
- }
-}
-
pub fn update_title(state: &mut State) {
- let Some(plugin_id) = state.plugin_id else {
+ let Some(tab_id) = state.sequence_tab_id else {
return;
};
+ let tab_id_u64 = tab_id as u64;
- let title = if state.all_commands_are_pending() {
- "Run one or more commands in sequence"
- } else {
- let has_running_commands = state
- .execution
- .all_commands
- .iter()
- .any(|c| matches!(c.get_status(), CommandStatus::Running(..)));
- let has_interrupted_commands = state
+ // All pending and not yet running
+ if state.all_commands_are_pending() && !state.execution.is_running {
+ rename_tab_with_id(tab_id_u64, "Run one or more commands in sequence");
+ return;
+ }
+
+ // Currently running: "<cmd> <n>/<total> <spinner>"
+ let has_running = state
+ .execution
+ .all_commands
+ .iter()
+ .any(|c| matches!(c.get_status(), CommandStatus::Running(..)));
+ if has_running {
+ let idx = state.execution.current_running_command_index;
+ let total = state.execution.all_commands.len();
+ let cmd_text = state
.execution
.all_commands
- .iter()
- .any(|c| matches!(c.get_status(), CommandStatus::Interrupted(..)));
- let all_commands_complete = state
+ .get(idx)
+ .map(|c| c.get_text())
+ .unwrap_or_default();
+ let spinner = components::get_spinner_frame(state.layout.spinner_frame);
+ rename_tab_with_id(
+ tab_id_u64,
+ &format!("{} {}/{} {}", cmd_text, idx + 1, total, spinner),
+ );
+ return;
+ }
+
+ // Interrupted: "<cmd> [INTERRUPTED]"
+ if let Some(cmd) = state
+ .execution
+ .all_commands
+ .iter()
+ .find(|c| matches!(c.get_status(), CommandStatus::Interrupted(_)))
+ {
+ let status_str =
+ components::format_status_text(&cmd.get_status(), state.layout.spinner_frame);
+ rename_tab_with_id(tab_id_u64, &format!("{} {}", cmd.get_text(), status_str));
+ return;
+ }
+
+ // All exited
+ let all_exited = state
+ .execution
+ .all_commands
+ .iter()
+ .all(|c| matches!(c.get_status(), CommandStatus::Exited(..)));
+ if all_exited {
+ let all_success = state
.execution
.all_commands
.iter()
- .all(|c| matches!(c.get_status(), CommandStatus::Exited(..)));
- if has_running_commands {
- let current = state.execution.current_running_command_index + 1;
- let total = state.execution.all_commands.len();
- return rename_plugin_pane(plugin_id, &format!("Running {}/{}", current, total));
- } else if has_interrupted_commands {
- "Sequence Interrupted"
- } else if all_commands_complete {
- "Sequence Complete"
+ .all(|c| matches!(c.get_status(), CommandStatus::Exited(Some(0), _)));
+ if all_success {
+ rename_tab_with_id(tab_id_u64, "Sequence Complete");
} else {
- "Stopped"
+ // Show first command with non-zero or unknown exit
+ if let Some(cmd) = state
+ .execution
+ .all_commands
+ .iter()
+ .find(|c| !matches!(c.get_status(), CommandStatus::Exited(Some(0), _)))
+ {
+ let status_str =
+ components::format_status_text(&cmd.get_status(), state.layout.spinner_frame);
+ rename_tab_with_id(tab_id_u64, &format!("{} {}", cmd.get_text(), status_str));
+ } else {
+ rename_tab_with_id(tab_id_u64, "Sequence Complete");
+ }
}
- };
- rename_plugin_pane(plugin_id, title);
+ return;
+ }
+
+ // Partial (chain condition failed): show last exited command with its status
+ if let Some(cmd) = state
+ .execution
+ .all_commands
+ .iter()
+ .rev()
+ .find(|c| matches!(c.get_status(), CommandStatus::Exited(..)))
+ {
+ let status_str =
+ components::format_status_text(&cmd.get_status(), state.layout.spinner_frame);
+ rename_tab_with_id(tab_id_u64, &format!("{} {}", cmd.get_text(), status_str));
+ } else {
+ rename_tab_with_id(tab_id_u64, "Stopped");
+ }
+}
+
+fn ensure_spinner_running(state: &mut State) {
+ if !state.layout.spinner_timer_scheduled {
+ set_timeout(0.1);
+ state.layout.spinner_timer_scheduled = true;
+ }
}
diff --git a/default-plugins/sequence/src/state/command_entry.rs b/default-plugins/sequence/src/state/command_entry.rs
index 96a0020b..4ca9350a 100644
--- a/default-plugins/sequence/src/state/command_entry.rs
+++ b/default-plugins/sequence/src/state/command_entry.rs
@@ -1,6 +1,5 @@
use super::{ChainType, CommandStatus};
use std::path::PathBuf;
-use std::time::Instant;
use zellij_tile::prelude::PaneId;
#[derive(Debug, Clone)]
@@ -9,7 +8,6 @@ pub struct CommandEntry {
cwd: Option<PathBuf>,
pub(super) chain_type: ChainType,
pub(super) status: CommandStatus,
- pub(super) start_time: std::time::Instant,
}
impl Default for CommandEntry {
@@ -19,7 +17,6 @@ impl Default for CommandEntry {
cwd: None,
chain_type: ChainType::default(),
status: CommandStatus::default(),
- start_time: Instant::now(),
}
}
}
@@ -32,10 +29,6 @@ impl CommandEntry {
..Default::default()
}
}
- pub fn with_and(mut self) -> Self {
- self.chain_type = ChainType::And;
- self
- }
pub fn get_text(&self) -> String {
self.text.clone()
}
@@ -74,9 +67,6 @@ impl CommandEntry {
pub fn clear_status(&mut self) {
self.status = CommandStatus::Pending;
}
- pub fn cycle_chain_type(&mut self) {
- self.chain_type.cycle_next();
- }
pub fn get_cwd(&self) -> Option<PathBuf> {
self.cwd.clone()
}
diff --git a/default-plugins/sequence/src/state/command_parser.rs b/default-plugins/sequence/src/state/command_parser.rs
index 7f0898f5..35935cd3 100644
--- a/default-plugins/sequence/src/state/command_parser.rs
+++ b/default-plugins/sequence/src/state/command_parser.rs
@@ -1,4 +1,4 @@
-use super::ChainType;
+use super::{ChainType, CommandEntry};
#[derive(Debug, Clone, Copy, PartialEq)]
enum ParseState {
@@ -104,63 +104,105 @@ pub fn split_by_chain_operators(text: &str) -> Vec<(String, Option<ChainType>)>
segments
}
-pub fn detect_chain_operator_at_end(text: &str) -> Option<(String, ChainType)> {
- let segments = split_by_chain_operators(text);
-
- if segments.is_empty() {
- return None;
- }
+pub fn detect_cd_command(text: &str) -> Option<String> {
+ let trimmed = text.trim();
- if segments.len() == 1 {
- if let Some((text, Some(chain_type))) = segments.first() {
- return Some((text.clone(), *chain_type));
- }
- return None;
+ if trimmed == "cd" {
+ return Some("~".to_string());
}
- if let Some((first_text, Some(chain_type))) = segments.first() {
- return Some((first_text.clone(), *chain_type));
+ if trimmed.starts_with("cd ") {
+ let path = trimmed[3..].trim();
+ return Some(path.to_string());
}
None
}
-pub fn get_remaining_after_first_segment(text: &str) -> Option<String> {
- let segments = split_by_chain_operators(text);
+/// Serialize a sequence of commands to editor format.
+/// CWD changes are emitted as `cd <path>` lines; chain type as trailing ` &&`, ` ||`, ` ;`.
+pub fn serialize_sequence_to_editor(commands: &[CommandEntry]) -> String {
+ let mut result = String::new();
- if segments.len() <= 1 {
- return None;
- }
+ result.push_str("# One cmd per line. Use && || ; to chain. cd <path> sets cwd.\n");
+ result.push_str("\n");
- let mut result = String::new();
- for (i, (segment_text, chain_type_opt)) in segments.iter().enumerate().skip(1) {
- if i > 1 {
- if let Some(chain_type) = chain_type_opt {
- result.push_str(&format!(" {} ", chain_type.as_str()));
- }
+ let mut prev_cwd: Option<std::path::PathBuf> = None;
+
+ for cmd in commands {
+ if cmd.get_text().trim().is_empty() {
+ continue;
}
- result.push_str(segment_text);
- if let Some(chain_type) = chain_type_opt {
- result.push_str(&format!(" {} ", chain_type.as_str()));
+
+ let cmd_cwd = cmd.get_cwd();
+ if cmd_cwd != prev_cwd && cmd_cwd.is_some() {
+ let path = cmd_cwd.as_ref().unwrap();
+ result.push_str(&format!("cd {}\n", path.display()));
}
+ prev_cwd = cmd_cwd;
+
+ let op_suffix = match cmd.get_chain_type() {
+ ChainType::And => " &&",
+ ChainType::Or => " ||",
+ ChainType::Then => " ;",
+ ChainType::None => "",
+ };
+
+ result.push_str(&format!("{}{}\n", cmd.get_text(), op_suffix));
}
- Some(result.trim().to_string())
+ result
}
-pub fn detect_cd_command(text: &str) -> Option<String> {
- let trimmed = text.trim();
+/// Parse command text into a Vec<CommandEntry>.
+/// `cd <path>` lines set the cwd for subsequent commands.
+/// Blank lines and `#` comment lines are ignored.
+pub fn parse_commands(
+ contents: &str,
+ initial_cwd: Option<std::path::PathBuf>,
+) -> Vec<CommandEntry> {
+ use crate::path_formatting;
+ let mut commands = Vec::new();
+ let mut current_cwd = initial_cwd;
- if trimmed == "cd" {
- return Some("~".to_string());
+ for line in contents.lines() {
+ let trimmed = line.trim();
+ if trimmed.is_empty() || trimmed.starts_with('#') {
+ continue;
+ }
+
+ // Split by chain operators first so that "cd /path &&" doesn't absorb the operator
+ // into the path. Each resulting segment is then checked for cd independently.
+ let segments = split_by_chain_operators(trimmed);
+ for (text, chain_type_opt) in segments {
+ let text = text.trim().to_string();
+ if text.is_empty() {
+ continue;
+ }
+ if let Some(path) = detect_cd_command(&text) {
+ if let Some(new_cwd) = path_formatting::resolve_path(current_cwd.as_ref(), &path) {
+ current_cwd = Some(new_cwd);
+ }
+ // cd segments are not added as command entries; their chain type is discarded
+ } else {
+ let mut entry = CommandEntry::new(&text, current_cwd.clone());
+ if let Some(chain_type) = chain_type_opt {
+ entry.set_chain_type(chain_type);
+ }
+ commands.push(entry);
+ }
+ }
}
- if trimmed.starts_with("cd ") {
- let path = trimmed[3..].trim();
- return Some(path.to_string());
+ // Commands with no explicit operator default to &&
+ if !commands.is_empty() {
+ let last_idx = commands.len() - 1;
+ for cmd in &mut commands[..last_idx] {
+ cmd.fill_chain_type_if_empty();
+ }
}
- None
+ commands
}
#[cfg(test)]
@@ -278,36 +320,6 @@ mod tests {
}
#[test]
- fn test_detect_operator_just_typed_and() {
- let result = detect_chain_operator_at_end("ls &&");
- assert_eq!(result, Some(("ls".to_string(), ChainType::And)));
- }
-
- #[test]
- fn test_detect_operator_just_typed_or() {
- let result = detect_chain_operator_at_end("cmd ||");
- assert_eq!(result, Some(("cmd".to_string(), ChainType::Or)));
- }
-
- #[test]
- fn test_detect_operator_just_typed_semicolon() {
- let result = detect_chain_operator_at_end("echo ;");
- assert_eq!(result, Some(("echo".to_string(), ChainType::Then)));
- }
-
- #[test]
- fn test_detect_operator_no_operator() {
- let result = detect_chain_operator_at_end("ls");
- assert_eq!(result, None);
- }
-
- #[test]
- fn test_detect_operator_in_quotes() {
- let result = detect_chain_operator_at_end("echo '&&'");
- assert_eq!(result, None);
- }
-
- #[test]
fn test_whitespace_trimming() {
let result = split_by_chain_operators(" cmd1 && cmd2 ");
assert_eq!(
@@ -324,4 +336,35 @@ mod tests {
let result = split_by_chain_operators("ls && ");
assert_eq!(result, vec![("ls".to_string(), Some(ChainType::And))]);
}
+
+ #[test]
+ fn test_editor_file_no_operator_defaults_to_and() {
+ let contents = "cmd1\ncmd2\ncmd3";
+ let result = parse_commands(contents, None);
+ assert_eq!(result.len(), 3);
+ assert_eq!(result[0].get_chain_type(), ChainType::And);
+ assert_eq!(result[1].get_chain_type(), ChainType::And);
+ assert_eq!(result[2].get_chain_type(), ChainType::None);
+ }
+
+ #[test]
+ fn test_editor_file_explicit_operator_preserved() {
+ let contents = "cmd1 ||\ncmd2";
+ let result = parse_commands(contents, None);
+ assert_eq!(result.len(), 2);
+ assert_eq!(result[0].get_chain_type(), ChainType::Or);
+ assert_eq!(result[1].get_chain_type(), ChainType::None);
+ }
+
+ #[test]
+ fn test_cd_with_chain_operator_does_not_absorb_operator_into_path() {
+ use super::super::CommandEntry;
+ use std::path::PathBuf;
+
+ let contents = "cd /some/path && cargo build";
+ let result = parse_commands(contents, None);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].get_text(), "cargo build");
+ assert_eq!(result[0].get_cwd(), Some(PathBuf::from("/some/path")));
+ }
}
diff --git a/default-plugins/sequence/src/state/editing.rs b/default-plugins/sequence/src/state/editing.rs
deleted file mode 100644
index 3deac619..00000000
--- a/default-plugins/sequence/src/state/editing.rs
+++ /dev/null
@@ -1,80 +0,0 @@
-use crate::path_formatting;
-use crate::state::CommandEntry;
-use crate::ui::text_input::TextInput;
-use std::path::PathBuf;
-
-pub struct Editing {
- pub editing_input: Option<TextInput>,
-}
-
-impl Editing {
- pub fn new() -> Self {
- Self {
- editing_input: Some(TextInput::new("".to_owned())),
- }
- }
-
- pub fn start_editing(&mut self, text: String) {
- self.editing_input = Some(TextInput::new(text));
- }
-
- pub fn cancel_editing(&mut self) {
- self.editing_input = None;
- }
-
- pub fn input_text(&self) -> Option<String> {
- self.editing_input.as_ref().map(|e| e.get_text().to_owned())
- }
-
- pub fn set_input_text(&mut self, text: String) {
- self.editing_input.as_mut().map(|e| e.set_text(text));
- }
-
- pub fn handle_submit(
- &mut self,
- selection_index: Option<usize>,
- all_commands: &mut Vec<CommandEntry>,
- current_cwd: &Option<PathBuf>,
- ) -> (bool, Option<usize>) {
- let mut handled_internally = false;
- let mut new_selection_index = None;
-
- if let Some(current_text) = self.editing_input.as_ref().map(|i| i.get_text()) {
- if let Some(index) = selection_index {
- if let Some(command) = all_commands.get_mut(index) {
- if current_text.starts_with("cd ") || current_text == "cd" {
- let path = if current_text == "cd" {
- "~"
- } else {
- current_text[3..].trim()
- };
-
- if let Some(new_cwd) =
- path_formatting::resolve_path(current_cwd.as_ref(), path)
- {
- command.set_cwd(Some(new_cwd));
- }
-
- command.set_text("".to_owned());
- self.editing_input.as_mut().map(|c| c.clear());
- new_selection_index = selection_index; // remain editing after a cd command
- handled_internally = true;
- return (handled_internally, new_selection_index); // avoid setting edit_input to None below, we
- // still want to be in editing mode after
- // changing directory with cd
- } else {
- command.set_text(current_text.to_owned());
- }
- }
- }
- }
- self.editing_input = None;
- (handled_internally, new_selection_index)
- }
-}
-
-impl Default for Editing {
- fn default() -> Self {
- Self::new()
- }
-}
diff --git a/default-plugins/sequence/src/state/execution.rs b/default-plugins/sequence/src/state/execution.rs
index fb194f61..42d4112a 100644
--- a/default-plugins/sequence/src/state/execution.rs
+++ b/default-plugins/sequence/src/state/execution.rs
@@ -1,16 +1,13 @@
use crate::path_formatting::format_cwd;
-use crate::state::{ChainType, CommandEntry, CommandStatus};
-use std::collections::BTreeMap;
+use crate::state::{CommandEntry, CommandStatus};
use std::path::PathBuf;
-use std::time::Duration;
use zellij_tile::prelude::*;
pub struct Execution {
pub all_commands: Vec<CommandEntry>,
pub current_running_command_index: usize,
pub is_running: bool,
- pub sequence_id: u64,
- pub primary_pane_id_before_sequence: Option<PaneId>,
+ pub displayed_pane_id: Option<PaneId>,
}
impl Execution {
@@ -19,8 +16,7 @@ impl Execution {
all_commands: vec![CommandEntry::default()],
current_running_command_index: 0,
is_running: false,
- sequence_id: 0,
- primary_pane_id_before_sequence: None,
+ displayed_pane_id: None,
}
}
@@ -82,181 +78,6 @@ impl Execution {
copy_to_clipboard(text_to_copy);
}
}
-
- pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
- for command in self.all_commands.iter_mut() {
- if let CommandStatus::Pending | CommandStatus::Running(None) = command.get_status() {
- let cmd_text = command.get_text();
- if cmd_text == command_text {
- command.set_status(CommandStatus::Running(Some(pane_id)));
- break;
- }
- }
- }
- }
-
- pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
- let mut updated = false;
- for command in self.all_commands.iter_mut() {
- let status = command.get_status();
- let pane_id_opt = match &status {
- CommandStatus::Running(pid) => *pid,
- CommandStatus::Exited(_, pid) => *pid,
- CommandStatus::Interrupted(pid) => *pid,
- _ => None,
- };
-
- if let Some(pane_id) = pane_id_opt {
- let mut pane_found = false;
- for (_tab_index, panes) in &pane_manifest.panes {
- for pane_info in panes {
- let pane_matches = match pane_id {
- PaneId::Terminal(id) => !pane_info.is_plugin && pane_info.id == id,
- PaneId::Plugin(id) => pane_info.is_plugin && pane_info.id == id,
- };
-
- if pane_matches {
- pane_found = true;
- if pane_info.exited {
- command.set_status(CommandStatus::Exited(
- pane_info.exit_status,
- Some(pane_id),
- ));
- updated = true;
- }
- break;
- }
- }
- if pane_found {
- break;
- }
- }
-
- if !pane_found && command.start_time.elapsed() > Duration::from_millis(400) {
- match command.get_status() {
- CommandStatus::Running(_) => {
- eprintln!(
- "Pane {:?} was closed while running, setting pane_id to None",
- pane_id
- );
- command.set_status(CommandStatus::Running(None));
- updated = true;
- },
- CommandStatus::Exited(exit_code, _) => {
- eprintln!(
- "Pane {:?} was closed after exiting, setting pane_id to None",
- pane_id
- );
- command.set_status(CommandStatus::Exited(exit_code, None));
- updated = true;
- },
- CommandStatus::Interrupted(_) => {
- eprintln!("Pane {:?} was closed after being interrupted, setting pane_id to None", pane_id);
- command.set_status(CommandStatus::Interrupted(None));
- updated = true;
- },
- _ => {},
- }
- }
- }
- }
- updated
- }
-
- pub fn update_sequence_stopped_state(&mut self) -> bool {
- let mut needs_rerender = false;
- if self.is_running {
- let current_idx = self.current_running_command_index;
- if let Some(command) = self.all_commands.get_mut(current_idx) {
- if let CommandStatus::Exited(exit_code, _) = command.get_status() {
- let should_stop;
- if current_idx >= self.all_commands.len().saturating_sub(1) {
- should_stop = true;
- } else {
- if let Some(chain_type) = &self
- .all_commands
- .get(current_idx)
- .map(|c| c.get_chain_type())
- {
- match chain_type {
- ChainType::And => {
- should_stop = exit_code.unwrap_or(0) != 0;
- },
- ChainType::Or => {
- should_stop = exit_code.unwrap_or(0) == 0;
- },
- ChainType::Then => {
- should_stop = false;
- },
- ChainType::None => {
- should_stop = true;
- },
- }
- } else {
- should_stop = true;
- }
- };
- if should_stop {
- self.is_running = false;
- }
- }
- }
- needs_rerender = true;
- }
- needs_rerender
- }
-
- pub fn execute_command_sequence(
- &mut self,
- shell: &Option<PathBuf>,
- global_cwd: &Option<PathBuf>,
- primary_pane_id: Option<PaneId>,
- ) {
- use zellij_tile::prelude::actions::{Action, RunCommandAction};
-
- self.all_commands.retain(|c| !c.is_empty());
-
- let Some(first_active_sequence_command) = self.get_first_command() else {
- return;
- };
-
- let shell = shell.clone().unwrap_or_else(|| PathBuf::from("/bin/bash"));
-
- let first_command = first_active_sequence_command.get_text();
- let first_chain_type = first_active_sequence_command.get_chain_type();
- let command_cwd = first_active_sequence_command
- .get_cwd()
- .or_else(|| global_cwd.clone());
-
- let command = RunCommandAction {
- command: shell.clone(),
- args: vec!["-ic".to_string(), first_command.trim().to_string()],
- cwd: command_cwd,
- hold_on_close: true,
- ..Default::default()
- };
-
- let placement = NewPanePlacement::InPlace {
- pane_id_to_replace: primary_pane_id,
- close_replaced_pane: false,
- borderless: Some(false),
- };
-
- let action = Action::NewBlockingPane {
- placement,
- command: Some(command),
- pane_name: Some(first_command.trim().to_string()),
- unblock_condition: first_chain_type.to_unblock_condition(),
- near_current_pane: true,
- };
-
- self.sequence_id += 1;
-
- let mut context = BTreeMap::new();
- context.insert("sequence_id".to_string(), self.sequence_id.to_string());
- run_action(action, context);
- self.set_command_status(0, CommandStatus::Running(None));
- }
}
impl Default for Execution {
diff --git a/default-plugins/sequence/src/state/layout.rs b/default-plugins/sequence/src/state/layout.rs
index 65a267e0..6b070778 100644
--- a/default-plugins/sequence/src/state/layout.rs
+++ b/default-plugins/sequence/src/state/layout.rs
@@ -1,10 +1,4 @@
-use crate::state::positioning;
-use std::path::PathBuf;
-use zellij_tile::prelude::*;
-
pub struct Layout {
- pub needs_reposition: bool,
- pub cached_cursor_position: Option<(usize, usize)>,
pub spinner_frame: usize,
pub spinner_timer_scheduled: bool,
}
@@ -12,58 +6,10 @@ pub struct Layout {
impl Layout {
pub fn new() -> Self {
Self {
- needs_reposition: false,
- cached_cursor_position: None,
spinner_frame: 0,
spinner_timer_scheduled: false,
}
}
-
- pub fn reposition_plugin_based_on_sequence_state(
- &mut self,
- is_running: bool,
- first_command_pane_id: Option<PaneId>,
- pane_manifest: &PaneManifest,
- plugin_id: Option<u32>,
- total_viewport_columns: Option<usize>,
- total_viewport_rows: Option<usize>,
- total_commands: usize,
- _selected_index: Option<usize>,
- _running_index: usize,
- _cwd: &Option<PathBuf>,
- ) {
- let Some(plugin_id) = plugin_id else {
- return;
- };
- let (Some(total_cols), Some(total_rows)) = (total_viewport_columns, total_viewport_rows)
- else {
- return;
- };
-
- if !self.needs_reposition || !is_running {
- return;
- }
-
- let Some(first_pane_id) = first_command_pane_id else {
- return;
- };
-
- for (_tab_index, panes) in &pane_manifest.panes {
- for pane in panes {
- if !pane.is_plugin && PaneId::Terminal(pane.id) == first_pane_id {
- positioning::reposition_plugin_for_sequence(
- plugin_id,
- total_cols,
- total_rows,
- total_commands,
- );
-
- self.needs_reposition = false;
- return;
- }
- }
- }
- }
}
impl Default for Layout {
diff --git a/default-plugins/sequence/src/state/mod.rs b/default-plugins/sequence/src/state/mod.rs
index e3fbf3ff..148e6702 100644
--- a/default-plugins/sequence/src/state/mod.rs
+++ b/default-plugins/sequence/src/state/mod.rs
@@ -2,28 +2,25 @@ mod chain_type;
mod command_entry;
mod command_parser;
mod command_status;
-mod editing;
mod execution;
mod layout;
-pub mod positioning;
mod selection;
+mod sequence_mode;
pub use chain_type::ChainType;
pub use command_entry::CommandEntry;
-pub use command_parser::{
- detect_cd_command, detect_chain_operator_at_end, get_remaining_after_first_segment,
- split_by_chain_operators,
-};
+pub use command_parser::{parse_commands, serialize_sequence_to_editor};
pub use command_status::CommandStatus;
-pub use editing::Editing;
pub use execution::Execution;
pub use layout::Layout;
pub use selection::Selection;
+pub use sequence_mode::SequenceMode;
-use crate::ui::text_input::TextInput;
+use std::collections::BTreeMap;
use std::path::PathBuf;
use zellij_tile::prelude::*;
+#[derive(Default)]
pub struct State {
pub shell: Option<PathBuf>,
pub plugin_id: Option<u32>,
@@ -31,291 +28,85 @@ pub struct State {
pub own_rows: Option<usize>,
pub own_columns: Option<usize>,
pub cwd: Option<PathBuf>,
- pub original_pane_id: Option<PaneId>,
- pub primary_pane_id: Option<PaneId>,
- pub is_first_run: bool,
- pub pane_manifest: Option<PaneManifest>,
pub total_viewport_columns: Option<usize>,
pub total_viewport_rows: Option<usize>,
pub selection: Selection,
- pub editing: Editing,
pub execution: Execution,
pub layout: Layout,
pub current_position: Option<FloatingPaneCoordinates>,
+ pub mode: SequenceMode,
+ pub editor_pane_id: Option<PaneId>,
+ pub editor_temp_file: Option<PathBuf>,
+ pub sequence_tab_id: Option<usize>,
+ pub blocking_pipe_id: Option<String>,
}
impl State {
- pub fn new() -> Self {
- Self {
- shell: None,
- plugin_id: None,
- client_id: None,
- own_rows: None,
- own_columns: None,
- cwd: None,
- original_pane_id: None,
- primary_pane_id: None,
- is_first_run: true,
- pane_manifest: None,
- total_viewport_columns: None,
- total_viewport_rows: None,
- selection: Selection::new(),
- editing: Editing::new(),
- execution: Execution::new(),
- layout: Layout::new(),
- current_position: None,
- }
- }
-
pub fn set_plugin_id(&mut self, plugin_id: u32) {
self.plugin_id = Some(plugin_id);
}
- /// Check if the sequence has finished executing (all commands are done)
- pub fn has_finished(&self) -> bool {
- self.execution
- .all_commands
- .iter()
- .all(|command| matches!(command.status, CommandStatus::Exited(_, _)))
- }
- pub fn add_empty_command_after_current_selected(&mut self) {
- self.selection.add_empty_command_after_current_selected(
- &mut self.execution.all_commands,
- &mut self.editing,
- &self.cwd,
- );
- }
-
- pub fn current_selected_command_is_empty(&self) -> bool {
- self.selection
- .current_selected_command_is_empty(&self.execution.all_commands, &self.editing)
- }
-
- pub fn remove_current_selected_command(&mut self) {
- let removed_pane_id = self
- .selection
- .remove_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
- if let Some(removed_pane_id) = removed_pane_id {
- close_pane_with_id(removed_pane_id);
- }
- }
-
- pub fn clear_current_selected_command(&mut self) {
- self.selection
- .clear_current_selected_command(&mut self.execution.all_commands, &mut self.editing);
- }
-
pub fn move_selection_up(&mut self) {
- self.selection
- .move_up(&mut self.execution.all_commands, &mut self.editing);
- if let Some(pane_id) = self
- .current_selected_command()
- .and_then(|c| c.get_pane_id())
- {
- show_pane_with_id(pane_id, true, false);
- }
+ self.selection.move_up(&self.execution.all_commands);
+ self.show_selected_pane();
}
pub fn move_selection_down(&mut self) {
- self.selection
- .move_down(&mut self.execution.all_commands, &mut self.editing);
- if let Some(pane_id) = self
- .current_selected_command()
- .and_then(|c| c.get_pane_id())
- {
- show_pane_with_id(pane_id, true, false);
- }
- }
- pub fn start_editing_selected(&mut self) {
- if let Some(current_command_text) = self.current_selected_command().map(|c| c.get_text()) {
- self.editing.start_editing(current_command_text);
- }
- }
-
- pub fn cancel_editing_selected(&mut self) {
- self.editing.cancel_editing();
- }
-
- pub fn editing_input_text(&self) -> Option<String> {
- self.editing.input_text()
- }
-
- pub fn set_editing_input_text(&mut self, text: String) {
- self.editing.set_input_text(text);
- }
- fn handle_first_pasted_segment(
- &mut self,
- current_text: &str,
- segment_text: &str,
- chain_type_opt: &Option<ChainType>,
- ) {
- let new_text = if current_text.trim().is_empty() {
- segment_text.to_string()
- } else {
- format!("{}{}", current_text, segment_text)
- };
-
- if let Some(path) = detect_cd_command(&new_text) {
- use crate::path_formatting;
- if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) {
- self.current_selected_command_mut().map(|c| {
- c.set_cwd(Some(new_cwd));
- c.set_text("".to_owned());
- if let Some(chain_type) = chain_type_opt {
- c.set_chain_type(*chain_type);
- }
- });
- return;
- }
- }
-
- self.current_selected_command_mut().map(|c| {
- c.set_text(new_text);
- if let Some(chain_type) = chain_type_opt {
- c.set_chain_type(*chain_type);
- }
- });
+ self.selection.move_down(&self.execution.all_commands);
+ self.show_selected_pane();
}
- fn insert_new_pasted_segment(
- &mut self,
- segment_text: &str,
- chain_type_opt: &Option<ChainType>,
- is_last_line: bool,
- ) {
- use crate::path_formatting;
-
- let cd_path = detect_cd_command(segment_text);
-
- if let Some(path) = cd_path {
- if let Some(new_cwd) = path_formatting::resolve_path(self.cwd.as_ref(), &path) {
- self.current_selected_command_mut().map(|c| {
- c.set_cwd(Some(new_cwd));
- if let Some(chain_type) = chain_type_opt {
- c.set_chain_type(*chain_type);
- }
- });
- return;
+ pub fn show_selected_pane(&mut self) {
+ if self.mode == SequenceMode::Spread {
+ let all_pane_ids: Vec<PaneId> = self
+ .execution
+ .all_commands
+ .iter()
+ .filter_map(|c| c.get_pane_id())
+ .collect();
+ let selected_pane_id = self
+ .current_selected_command()
+ .and_then(|c| c.get_pane_id());
+ if let Some(selected) = selected_pane_id {
+ let to_unhighlight: Vec<PaneId> = all_pane_ids
+ .into_iter()
+ .filter(|&p| p != selected)
+ .collect();
+ highlight_and_unhighlight_panes(vec![selected], to_unhighlight);
}
- }
-
- let Some(new_selected_index) = self.selection.current_selected_command_index.map(|i| i + 1)
- else {
return;
- };
-
- let mut new_command = CommandEntry::new(segment_text, self.cwd.clone());
- if let Some(chain_type) = chain_type_opt {
- new_command.set_chain_type(*chain_type);
- } else if !is_last_line {
- new_command.set_chain_type(ChainType::And);
}
-
- self.execution
- .all_commands
- .insert(new_selected_index, new_command);
- self.selection.current_selected_command_index = Some(new_selected_index);
- }
-
- fn ensure_line_end_chain_type(&mut self) {
- if let Some(last_cmd_index) = self.selection.current_selected_command_index {
- self.execution
- .all_commands
- .get_mut(last_cmd_index)
- .map(|c| {
- if matches!(c.get_chain_type(), ChainType::None) {
- c.set_chain_type(ChainType::And);
- }
- });
- }
- }
-
- pub fn pasted_lines(&mut self, lines: Vec<&str>) {
- let Some(current_text) = self.editing_input_text() else {
- return;
- };
-
- for (line_index, line) in lines.iter().enumerate() {
- let segments = split_by_chain_operators(line);
- let is_last_line = line_index == lines.len().saturating_sub(1);
-
- for (seg_index, (segment_text, chain_type_opt)) in segments.iter().enumerate() {
- if line_index == 0 && seg_index == 0 {
- self.handle_first_pasted_segment(&current_text, segment_text, chain_type_opt);
- } else {
- self.insert_new_pasted_segment(segment_text, chain_type_opt, is_last_line);
- }
- }
-
- if !is_last_line {
- self.ensure_line_end_chain_type();
+ let target_pane_id = self
+ .current_selected_command()
+ .and_then(|c| c.get_pane_id());
+ if let (Some(displayed), Some(target)) = (self.execution.displayed_pane_id, target_pane_id)
+ {
+ if displayed != target {
+ replace_pane_with_existing_pane(displayed, target, true);
+ self.execution.displayed_pane_id = Some(target);
}
}
-
- self.start_editing_selected();
}
+
pub fn remove_empty_commands(&mut self) {
self.execution.remove_empty_commands();
}
- pub fn get_first_command(&self) -> Option<CommandEntry> {
- self.execution.get_first_command()
- }
-
- pub fn set_command_status(&mut self, command_index: usize, status: CommandStatus) {
- self.execution.set_command_status(command_index, status);
- }
-
- pub fn set_current_running_command_status(&mut self, status: CommandStatus) {
- self.execution.set_current_running_command_status(status);
- }
-
- pub fn get_current_running_command_status(&mut self) -> Option<CommandStatus> {
- self.execution.get_current_running_command_status()
- }
-
- pub fn handle_editing_submit(&mut self, current_cwd: &Option<PathBuf>) -> bool {
- let (handled_internally, new_selection_index) = self.editing.handle_submit(
- self.selection.current_selected_command_index,
- &mut self.execution.all_commands,
- current_cwd,
- );
- self.selection.current_selected_command_index = new_selection_index;
- handled_internally
- }
-
- pub fn update_pane_id_for_command(&mut self, pane_id: PaneId, command_text: &str) {
- self.execution
- .update_pane_id_for_command(pane_id, command_text);
- }
- pub fn update_exited_command_statuses(&mut self, pane_manifest: &PaneManifest) -> bool {
- self.execution.update_exited_command_statuses(pane_manifest)
- }
- pub fn update_sequence_stopped_state(&mut self) -> bool {
- self.execution.update_sequence_stopped_state()
- }
-
- pub fn cycle_chain_type(&mut self) {
- self.current_selected_command_mut()
- .map(|c| c.cycle_chain_type());
- }
-
- pub fn set_primary_pane_id_before_sequence(&mut self, pane_id: Option<PaneId>) {
- if pane_id.is_some() {
- self.execution.primary_pane_id_before_sequence = pane_id;
- }
- }
pub fn clear_all_commands(&mut self) {
self.execution.all_commands = vec![CommandEntry::new("", self.cwd.clone())];
- self.editing.editing_input = Some(TextInput::new("".to_owned()));
- self.selection.current_selected_command_index = Some(0);
+ self.selection.current_selected_command_index = None;
self.execution.current_running_command_index = 0;
}
- pub fn update_running_state(&mut self, primary_pane_id: Option<PaneId>) {
- self.remove_empty_commands();
- self.set_primary_pane_id_before_sequence(primary_pane_id);
- self.layout.needs_reposition = true;
- self.execution.is_running = true;
+
+ pub fn load_commands(&mut self, command_string: &str, cwd_override: Option<PathBuf>) {
+ let effective_cwd = cwd_override.or_else(|| self.cwd.clone());
+ let commands = parse_commands(command_string, effective_cwd);
+ if commands.is_empty() {
+ return;
+ }
+ self.execution.all_commands = commands;
+ self.execution.current_running_command_index = 0;
+ self.selection.current_selected_command_index = None;
}
pub fn can_run_sequence(&self) -> bool {
@@ -332,6 +123,31 @@ impl State {
};
self.execution.all_commands.get_mut(i)
}
+
+ /// Serialize current sequence to a temp file and open it in the user's $EDITOR.
+ /// Stores the editor pane id and temp file path for later retrieval.
+ pub fn open_editor(&mut self) {
+ if self.execution.is_running {
+ return;
+ }
+ let Some(plugin_id) = self.plugin_id else {
+ return;
+ };
+
+ let serialized = serialize_sequence_to_editor(&self.execution.all_commands);
+ let temp_path = PathBuf::from(format!("/tmp/zellij-sequence-{}.sh", plugin_id));
+
+ if let Err(e) = std::fs::write(&temp_path, &serialized) {
+ eprintln!("Failed to write sequence to temp file: {}", e);
+ return;
+ }
+
+ let file_to_open = FileToOpen::new(temp_path.clone());
+ let editor_pane_id = open_file_in_place_of_plugin(file_to_open, false, BTreeMap::new());
+ self.editor_pane_id = editor_pane_id;
+ self.editor_temp_file = Some(temp_path);
+ }
+
pub fn reposition_plugin(&mut self) -> bool {
let mut repositioned = false;
let Some(plugin_id) = self.plugin_id else {
@@ -343,82 +159,62 @@ impl State {
let Some(total_viewport_columns) = self.total_viewport_columns else {
return repositioned;
};
+ if self.all_commands_are_pending() {
+ return false;
+ }
+
let total_commands = std::cmp::max(self.execution.all_commands.iter().len(), 1);
let height_padding = 7;
- if self.all_commands_are_pending() {
- let initial_height = total_commands + height_padding;
- let y = total_viewport_rows.saturating_sub(initial_height) / 2;
- let coordinates = FloatingPaneCoordinates::new(
- Some(format!("25%")),
- Some(format!("{}", y)),
- Some(format!("50%")),
- Some(format!("{}", initial_height)),
- Some(false), // should not be pinned when running sequence
- Some(false),
- );
- coordinates.map(|coordinates| {
- if Some(&coordinates) != self.current_position.as_ref() {
- repositioned = true;
- self.current_position = Some(coordinates.clone());
- change_floating_panes_coordinates(vec![(
- PaneId::Plugin(plugin_id),
- coordinates,
- )]);
- }
- });
- } else {
- // Calculate longest table row width
- let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd);
- let longest_command = self
- .execution
- .all_commands
- .iter()
- .map(|cmd| cmd.get_text().chars().count())
- .max()
- .unwrap_or(1);
- let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths(
- &self.execution.all_commands,
- self.layout.spinner_frame,
- );
+ // Calculate longest table row width
+ let longest_cwd_display = self.execution.longest_cwd_display(&self.cwd);
+ let longest_command = self
+ .execution
+ .all_commands
+ .iter()
+ .map(|cmd| cmd.get_text().chars().count())
+ .max()
+ .unwrap_or(1);
- let longest_line = crate::ui::components::calculate_longest_line(
- &longest_cwd_display,
- longest_command,
- max_chain_width,
- max_status_width,
- );
+ let (max_chain_width, max_status_width) = crate::ui::components::calculate_max_widths(
+ &self.execution.all_commands,
+ self.layout.spinner_frame,
+ );
- let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame
- let width = std::cmp::min(ui_width, total_viewport_columns / 2);
+ let longest_line = crate::ui::components::calculate_longest_line(
+ &longest_cwd_display,
+ longest_command,
+ max_chain_width,
+ max_status_width,
+ );
- let height = (height_padding + total_commands).min((total_viewport_rows * 25) / 100);
+ let ui_width = std::cmp::max(longest_line, 50) + 4; // 2 for ui-padding, 2 for pane frame
+ let width = std::cmp::min(ui_width, total_viewport_columns.saturating_sub(2));
- // Position: top-right with 1-space margins
- let x = total_viewport_columns.saturating_sub(width);
- let y = 1;
+ let height = (height_padding + total_commands).min((total_viewport_rows * 50) / 100);
- let coordinates = FloatingPaneCoordinates::new(
- Some(x.to_string()),
- Some(y.to_string()),
- Some(width.to_string()),
- Some(height.to_string()),
- Some(true), // should be pinned for sequence
- Some(false),
- );
- coordinates.map(|coordinates| {
- if Some(&coordinates) != self.current_position.as_ref() {
- self.current_position = Some(coordinates.clone());
- repositioned = true;
- change_floating_panes_coordinates(vec![(
- PaneId::Plugin(plugin_id),
- coordinates,
- )]);
- }
- });
- }
+ // Position: top-right with 1-space margins
+ let x = total_viewport_columns.saturating_sub(width);
+ let y = 1;
+
+ let coordinates = FloatingPaneCoordinates::new(
+ Some(x.to_string()),
+ Some(y.to_string()),
+ Some(width.to_string()),
+ Some(height.to_string()),
+ Some(true), // should be pinned for sequence
+ Some(false),
+ );
+ coordinates.map(|coordinates| {
+ if Some(&coordinates) != self.current_position.as_ref() {
+ self.current_position = Some(coordinates.clone());
+ repositioned = true;
+ change_floating_panes_coordinates(vec![(PaneId::Plugin(plugin_id), coordinates)]);
+ }
+ });
repositioned
}
+
pub fn all_commands_are_pending(&self) -> bool {
self.execution
.all_commands
@@ -432,17 +228,4 @@ impl State {
};
self.execution.all_commands.get(i)
}
-
- pub fn execute_command_sequence(&mut self) {
- self.execution
- .execute_command_sequence(&self.shell, &self.cwd, self.primary_pane_id);
- }
-}
-
-impl Default for State {
- fn default() -> Self {
- let mut state = Self::new();
- state.is_first_run = true;
- state
- }
}
diff --git a/default-plugins/sequence/src/state/positioning.rs b/default-plugins/sequence/src/state/positioning.rs
deleted file mode 100644
index b724df62..00000000
--- a/default-plugins/sequence/src/state/positioning.rs
+++ /dev/null
@@ -1,49 +0,0 @@
-use zellij_tile::prelude::*;
-
-pub fn reposition_plugin_for_sequence(
- plugin_id: u32,
- total_cols: usize,
- total_rows: usize,
- total_commands: usize,
-) {
- // Width: 30% of viewport
- let width = std::cmp::max((total_cols * 30) / 100, 50);
-
- // Height: UI overhead + commands, capped at 25% of viewport
- let overhead_rows = 6;
- let height = (overhead_rows + total_commands).min((total_rows * 25) / 100);
-
- // Position: top-right with 1-space margins
- let x = total_cols.saturating_sub(width);
- let y = 1;
-
- change_floating_pane_coordinates_absolute(
- plugin_id,
- Some(x),
- Some(y),
- Some(width),
- Some(height),
- true,
- );
-}
-
-pub fn change_floating_pane_coordinates_absolute(
- own_plugin_id: u32,
- x: Option<usize>,
- y: Option<usize>,
- width: Option<usize>,
- height: Option<usize>,
- should_be_pinned: bool,
-) {
- let coordinates = FloatingPaneCoordinates::new(
- x.map(|x| x.to_string()),
- y.map(|y| y.to_string()),
- width.map(|width| width.to_string()),
- height.map(|height| height.to_string()),
- Some(should_be_pinned),
- Some(false),
- );
- if let Some(coordinates) = coordinates {
- change_floating_panes_coordinates(vec![(PaneId::Plugin(own_plugin_id), coordinates)]);
- }
-}
diff --git a/default-plugins/sequence/src/state/selection.rs b/default-plugins/sequence/src/state/selection.rs
index 18f83bcd..2137f47d 100644
--- a/default-plugins/sequence/src/state/selection.rs
+++ b/default-plugins/sequence/src/state/selection.rs
@@ -1,115 +1,17 @@
-use crate::state::{CommandEntry, Editing};
-use std::path::PathBuf;
-use zellij_tile::prelude::*;
+use super::CommandEntry;
pub struct Selection {
pub current_selected_command_index: Option<usize>,
- pub scroll_offset: usize,
}
impl Selection {
pub fn new() -> Self {
Self {
- current_selected_command_index: Some(0),
- scroll_offset: 0,
+ current_selected_command_index: None,
}
}
- pub fn add_empty_command_after_current_selected(
- &mut self,
- all_commands: &mut Vec<CommandEntry>,
- editing: &mut Editing,
- cwd: &Option<PathBuf>,
- ) {
- self.save_editing_buffer_to_current_selected(all_commands, editing);
- editing.editing_input.as_mut().map(|i| i.clear());
- if !self.current_selected_command_is_empty(all_commands, editing) {
- self.current_selected_command_mut(all_commands)
- .map(|c| c.fill_chain_type_if_empty());
-
- if let Some(current_selected_command_index) = self.current_selected_command_index.take()
- {
- let new_command = CommandEntry::new("", cwd.clone());
- if current_selected_command_index == all_commands.len().saturating_sub(1) {
- all_commands.push(new_command);
- } else {
- all_commands.insert(current_selected_command_index + 1, new_command);
- }
-
- self.current_selected_command_index = Some(current_selected_command_index + 1);
- }
- }
- }
-
- pub fn current_selected_command_is_empty(
- &self,
- all_commands: &[CommandEntry],
- editing: &Editing,
- ) -> bool {
- let text_input_is_empty = editing
- .editing_input
- .as_ref()
- .map(|i| i.is_empty())
- .unwrap_or(true);
- let current_selected_command_is_empty = self
- .current_selected_command_index
- .and_then(|i| all_commands.get(i).map(|c| c.is_empty()))
- .unwrap_or(true);
- text_input_is_empty && current_selected_command_is_empty
- }
-
- pub fn remove_current_selected_command(
- &mut self,
- all_commands: &mut Vec<CommandEntry>,
- editing: &mut Editing,
- ) -> Option<PaneId> {
- // returns the pane_id of the removed command, if any
- let mut removed_pane_id = None;
- let Some(mut current_selected_command_index) = self.current_selected_command_index else {
- return removed_pane_id;
- };
- if all_commands.len() > 1 && all_commands.len() > current_selected_command_index {
- let command = all_commands.remove(current_selected_command_index);
- removed_pane_id = command.get_pane_id();
- } else {
- self.clear_current_selected_command(all_commands, editing);
- }
- if current_selected_command_index >= all_commands.len()
- && current_selected_command_index > 0
- {
- current_selected_command_index -= 1;
- }
- self.update_current_selected_command_index(
- current_selected_command_index,
- all_commands,
- editing,
- );
- if current_selected_command_index == all_commands.len().saturating_sub(1) {
- self.current_selected_command_mut(all_commands)
- .map(|c| c.clear_chain_type());
- }
- removed_pane_id
- }
-
- pub fn clear_current_selected_command(
- &mut self,
- all_commands: &mut [CommandEntry],
- editing: &mut Editing,
- ) {
- let Some(current_selected_command_index) = self.current_selected_command_index else {
- return;
- };
- self.current_selected_command_mut(all_commands)
- .map(|c| c.clear_text());
- self.clear_editing_buffer(editing);
- if current_selected_command_index == all_commands.len().saturating_sub(1) {
- self.current_selected_command_mut(all_commands)
- .map(|c| c.clear_chain_type());
- }
- }
-
- pub fn move_up(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
- self.save_editing_buffer_to_current_selected(all_commands, editing);
+ pub fn move_up(&mut self, all_commands: &[CommandEntry]) {
match self.current_selected_command_index.as_mut() {
Some(i) if *i > 0 => {
*i -= 1;
@@ -121,11 +23,9 @@ impl Selection {
self.current_selected_command_index = None;
},
}
- self.update_editing_buffer_to_current_selected(all_commands, editing);
}
- pub fn move_down(&mut self, all_commands: &mut [CommandEntry], editing: &mut Editing) {
- self.save_editing_buffer_to_current_selected(all_commands, editing);
+ pub fn move_down(&mut self, all_commands: &[CommandEntry]) {
match self.current_selected_command_index.as_mut() {
Some(i) if *i < all_commands.len().saturating_sub(1) => {
*i += 1;
@@ -137,73 +37,6 @@ impl Selection {
self.current_selected_command_index = None;
},
}
- self.update_editing_buffer_to_current_selected(all_commands, editing);
- }
-
- fn current_selected_command_mut<'a>(
- &self,
- all_commands: &'a mut [CommandEntry],
- ) -> Option<&'a mut CommandEntry> {
- let Some(i) = self.current_selected_command_index else {
- return None;
- };
- all_commands.get_mut(i)
- }
-
- fn current_selected_command<'a>(
- &self,
- all_commands: &'a [CommandEntry],
- ) -> Option<&'a CommandEntry> {
- let Some(i) = self.current_selected_command_index else {
- return None;
- };
- all_commands.get(i)
- }
-
- fn clear_editing_buffer(&self, editing: &mut Editing) {
- editing.editing_input.as_mut().map(|c| c.clear());
- }
-
- fn update_current_selected_command_index(
- &mut self,
- new_index: usize,
- all_commands: &[CommandEntry],
- editing: &mut Editing,
- ) {
- self.current_selected_command_index = Some(new_index);
- self.update_editing_buffer_to_current_selected(all_commands, editing);
- }
-
- fn get_text_of_current_selected_command(
- &self,
- all_commands: &[CommandEntry],
- ) -> Option<String> {
- self.current_selected_command(all_commands)
- .map(|c| c.get_text())
- }
-
- fn save_editing_buffer_to_current_selected(
- &self,
- all_commands: &mut [CommandEntry],
- editing: &mut Editing,
- ) {
- if let Some(text_input) = editing.editing_input.as_ref().map(|i| i.get_text()) {
- self.current_selected_command_mut(all_commands)
- .map(|c| c.set_text(text_input.to_owned()));
- }
- }
-
- fn update_editing_buffer_to_current_selected(
- &self,
- all_commands: &[CommandEntry],
- editing: &mut Editing,
- ) {
- let new_text_of_current_selected_command = self
- .get_text_of_current_selected_command(all_commands)
- .unwrap_or_else(|| String::new());
- if let Some(editing_input) = editing.editing_input.as_mut() {
- editing_input.set_text(new_text_of_current_selected_command);
- }
}
}
diff --git a/default-plugins/sequence/src/state/sequence_mode.rs b/default-plugins/sequence/src/state/sequence_mode.rs
new file mode 100644
index 00000000..3d079d70
--- /dev/null
+++ b/default-plugins/sequence/src/state/sequence_mode.rs
@@ -0,0 +1,6 @@
+#[derive(Default, Debug, Clone, PartialEq)]
+pub enum SequenceMode {
+ #[default]
+ SinglePane,
+ Spread,
+}
diff --git a/default-plugins/sequence/src/ui/components.rs b/default-plugins/sequence/src/ui/components.rs
index 3fe2ae1a..bc319567 100644
--- a/default-plugins/sequence/src/ui/components.rs
+++ b/default-plugins/sequence/src/ui/components.rs
@@ -6,6 +6,11 @@ use zellij_tile::prelude::*;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
+const INTRO_PART1: &str = "Paste a series of commands to run in sequence";
+const INTRO_PART2: &str = "or <e> to edit in your editor";
+const INTRO_FULL: &str =
+ "Paste a series of commands to run in sequence or <e> to edit in your editor";
+
const HELP_RUNNING_WITH_SELECTION: &str = "<Ctrl c> - interrupt, <Enter> - run from selected";
const HELP_RUNNING_NO_SELECTION: &str = "<Ctrl c> - interrupt, <↓↑> - navigate";
const HELP_STOPPED_WITH_SELECTION: &str =
@@ -13,15 +18,7 @@ const HELP_STOPPED_WITH_SELECTION: &str =
const HELP_STOPPED_NO_SELECTION: &str =
"<Ctrl w> - close all, <↓↑> - navigate, <Enter> - run from first";
-const HELP_ONE_PENDING_COMMAND: &str = "<Enter> - run, <Ctrl Enter> - add command";
-
-const HELP_ALL_COMMANDS_PENDING: &str =
- "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate";
-const HELP_ALL_COMMANDS_PENDING_WITH_SELECTION: &str =
- "<Enter> - run, <Ctrl Enter> - add command, <↓↑> - navigate, <e> - edit selected";
-
-const HELP_EDITING_FIRST_LINE: &str =
- "<Enter> - accept, <Ctrl Enter> - add command, <↓↑> - navigate";
+const HELP_PENDING: &str = "<e> - edit, <Enter> - run";
fn select_help_text(sequence: &State) -> &'static str {
let is_running = sequence
@@ -35,18 +32,9 @@ fn select_help_text(sequence: &State) -> &'static str {
.iter()
.all(|command| matches!(command.get_status(), CommandStatus::Pending));
let has_selection = sequence.selection.current_selected_command_index.is_some();
- let is_editing = sequence.editing.editing_input.is_some();
if all_pending && !sequence.execution.is_running {
- if sequence.execution.all_commands.len() == 1 {
- HELP_ONE_PENDING_COMMAND
- } else if has_selection && !is_editing {
- HELP_ALL_COMMANDS_PENDING_WITH_SELECTION
- } else if is_editing {
- HELP_EDITING_FIRST_LINE
- } else {
- HELP_ALL_COMMANDS_PENDING
- }
+ HELP_PENDING
} else if is_running {
if has_selection {
HELP_RUNNING_WITH_SELECTION
@@ -54,10 +42,8 @@ fn select_help_text(sequence: &State) -> &'static str {
HELP_RUNNING_NO_SELECTION
}
} else {
- if has_selection && !is_editing {
+ if has_selection {
HELP_STOPPED_WITH_SELECTION
- } else if is_editing {
- HELP_EDITING_FIRST_LINE
} else {
HELP_STOPPED_NO_SELECTION
}
@@ -71,7 +57,6 @@ fn style_help_text(text: &str) -> Text {
.color_substring(3, "<↓↑>")
.color_substring(3, "<Esc>")
.color_substring(3, "<Enter>")
- .color_substring(3, "<Ctrl Enter>")
.color_substring(3, "<e>")
}
@@ -79,33 +64,6 @@ pub fn render_help_lines(
sequence: &State,
max_width: Option<usize>,
) -> (Text, usize, Option<(Text, usize)>) {
- let is_editing = sequence.editing.editing_input.is_some();
-
- if is_editing {
- let help_text = select_help_text(sequence);
- let (truncated_text, help_len) = if let Some(width) = max_width {
- truncate_help_line(help_text, width)
- } else {
- (help_text.to_string(), help_text.chars().count())
- };
- let first_line = (style_help_text(&truncated_text).unbold_all(), help_len);
-
- let editing_help_text = "Navigate with cd, chain with ||, &&, ; ";
- let editing_help = Text::new(editing_help_text)
- .color_substring(3, "cd")
- .color_substring(3, "||")
- .color_substring(3, "&&")
- .color_substring(3, ";")
- .unbold_all();
- let editing_len = editing_help_text.len();
-
- return (
- first_line.0,
- first_line.1,
- Some((editing_help, editing_len)),
- );
- }
-
let help_text = select_help_text(sequence);
if let Some(width) = max_width {
@@ -139,6 +97,38 @@ pub fn render_help_lines(
}
}
+/// Returns `(text, x, y)` tuples for the main screen intro hint.
+/// The text is centered horizontally; if it does not fit on one line, two lines
+/// are returned separated by one blank line (1-line padding).
+pub fn render_intro_hint(rows: usize, cols: usize) -> Vec<(Text, usize, usize)> {
+ let full_len = INTRO_FULL.chars().count();
+
+ if full_len <= cols {
+ let x = cols.saturating_sub(full_len) / 2;
+ let y = rows / 2;
+ vec![(Text::new(INTRO_FULL).color_substring(3, "<e>"), x, y)]
+ } else {
+ let p1_len = INTRO_PART1.chars().count();
+ let p2_len = INTRO_PART2.chars().count();
+ let x1 = cols.saturating_sub(p1_len) / 2;
+ let x2 = cols.saturating_sub(p2_len) / 2;
+ // 3 display rows: line1, padding, line2 — center the group vertically
+ let start_y = rows.saturating_sub(3) / 2;
+ vec![
+ (
+ Text::new(INTRO_PART1).color_substring(3, "<e>"),
+ x1,
+ start_y,
+ ),
+ (
+ Text::new(INTRO_PART2).color_substring(3, "<e>"),
+ x2,
+ start_y + 2,
+ ),
+ ]
+ }
+}
+
fn split_help_line(help_text: &str, max_width: usize) -> Option<(String, String)> {
let text_width = help_text.chars().count();
@@ -200,7 +190,7 @@ fn chain_cell(chain_type: &crate::state::ChainType) -> Text {
}
}
-fn get_spinner_frame(frame: usize) -> &'static str {
+pub fn get_spinner_frame(frame: usize) -> &'static str {
SPINNER_FRAMES[frame % SPINNER_FRAMES.len()]
}
@@ -371,7 +361,8 @@ pub fn add_command_row(
None => return table,
};
- let cmd_text = &command.get_text();
+ let cmd_text = command.get_text();
+ let cmd_text = cmd_text.trim();
let chain_type = &command.get_chain_type();
let status = &command.get_status();
let command_cwd = command.get_cwd().or_else(|| state.cwd.clone());
@@ -398,26 +389,6 @@ pub fn add_command_row(
return table;
};
- if let Some(text_input) = &state.editing.editing_input {
- if state.selection.current_selected_command_index == Some(index) {
- let (truncated_editing_text, _) = truncate_middle(
- text_input.get_text(),
- available_cmd_width,
- Some(text_input.cursor_position()),
- );
- let (row, _) = command_sequence_row(
- state,
- &command_cwd,
- &truncated_editing_text,
- chain_type,
- &CommandStatus::Pending,
- false,
- overflow_indicator,
- );
- return table.add_styled_row(row);
- }
- }
-
let truncated_cmd_text = truncate_middle(cmd_text, available_cmd_width, None).0;
let (row, _) = command_sequence_row(
diff --git a/default-plugins/sequence/src/ui/fuzzy_complete.rs b/default-plugins/sequence/src/ui/fuzzy_complete.rs
deleted file mode 100644
index 0feae7e4..00000000
--- a/default-plugins/sequence/src/ui/fuzzy_complete.rs
+++ /dev/null
@@ -1,321 +0,0 @@
-use fuzzy_matcher::skim::SkimMatcherV2;
-use fuzzy_matcher::FuzzyMatcher;
-use std::collections::BTreeMap;
-use std::fs;
-use std::path::PathBuf;
-
-/// Result of a fuzzy completion operation
-#[derive(Debug, Clone)]
-pub struct CompletionResult {
- pub completed_text: String,
- pub score: i64,
- pub is_directory: bool,
- pub is_prefix_completion: bool, // True if this is the shortest of multiple matches with same prefix
-}
-
-/// Type of completion
-#[derive(Debug, Clone, Copy, PartialEq)]
-pub enum CompletionType {
- Command,
- Path,
-}
-
-/// Fuzzy match executables (commands) from the PATH
-pub fn fuzzy_complete_command(
- query: &str,
- executables: &BTreeMap<String, PathBuf>,
-) -> Option<CompletionResult> {
- if query.is_empty() {
- return None;
- }
-
- let matcher = SkimMatcherV2::default().ignore_case();
-
- // Collect all matches with their scores
- let mut all_matches: Vec<(String, i64)> = Vec::new();
-
- for executable_name in executables.keys() {
- if let Some((score, _indices)) = matcher.fuzzy_indices(executable_name, query) {
- all_matches.push((executable_name.clone(), score));
- }
- }
-
- if all_matches.is_empty() {
- return None;
- }
-
- // Sort by score (highest first), then by length (shortest first)
- all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())));
-
- // Get the best match
- let (best_name, best_score) = &all_matches[0];
-
- // Check if there are multiple matches that share the same prefix
- let is_prefix_completion = check_for_prefix_matches(&all_matches, best_name);
-
- Some(CompletionResult {
- completed_text: best_name.clone(),
- score: *best_score,
- is_directory: false, // Commands are not directories
- is_prefix_completion,
- })
-}
-
-/// Fuzzy match paths (directories and files) relative to the current working directory
-pub fn fuzzy_complete_path(query: &str, cwd: Option<&PathBuf>) -> Option<CompletionResult> {
- if query.is_empty() {
- return None;
- }
-
- let matcher = SkimMatcherV2::default().ignore_case();
-
- // Determine the base directory to search in
- let (base_dir, search_prefix) = if query.starts_with('/') {
- // Absolute path
- let parts: Vec<&str> = query.rsplitn(2, '/').collect();
- if parts.len() == 2 {
- // Has a directory component
- let dir = parts[1];
- let prefix = parts[0];
- (PathBuf::from(dir), prefix.to_string())
- } else {
- // Just "/"
- (PathBuf::from("/"), String::new())
- }
- } else if query.starts_with("~/") {
- // Home directory
- let home_dir = std::env::var("HOME").ok()?;
- let query_without_tilde = &query[2..];
- let parts: Vec<&str> = query_without_tilde.rsplitn(2, '/').collect();
- if parts.len() == 2 {
- let dir = PathBuf::from(&home_dir).join(parts[1]);
- let prefix = parts[0];
- (dir, prefix.to_string())
- } else {
- // Just "~/" or "~/something"
- (PathBuf::from(home_dir), query_without_tilde.to_string())
- }
- } else {
- // Relative path
- let base = cwd?;
- let parts: Vec<&str> = query.rsplitn(2, '/').collect();
- if parts.len() == 2 {
- // Has a directory component
- let dir = base.join(parts[1]);
- let prefix = parts[0];
- (dir, prefix.to_string())
- } else {
- // Just a name in the current directory
- (base.clone(), query.to_string())
- }
- };
-
- // Convert to host path
- let host_base_dir =
- PathBuf::from("/host").join(base_dir.strip_prefix("/").unwrap_or(&base_dir));
-
- // Read directory entries
- let entries = fs::read_dir(&host_base_dir).ok()?;
-
- // Collect all matches with their scores and directory status
- let mut all_matches: Vec<(String, i64, bool)> = Vec::new();
-
- for entry in entries.flatten() {
- if let Some(name) = entry.file_name().to_str() {
- // Skip hidden files unless the query starts with a dot
- if name.starts_with('.') && !search_prefix.starts_with('.') {
- continue;
- }
-
- if let Some((score, _indices)) = matcher.fuzzy_indices(name, &search_prefix) {
- // Check if this entry is a directory
- let is_dir = entry.metadata().ok().map(|m| m.is_dir()).unwrap_or(false);
- all_matches.push((name.to_string(), score, is_dir));
- }
- }
- }
-
- if all_matches.is_empty() {
- return None;
- }
-
- // Sort by score (highest first), then by length (shortest first)
- all_matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.len().cmp(&b.0.len())));
-
- // Get the best match
- let (name, score, is_directory) = all_matches[0].clone();
-
- // Check if there are multiple matches that share the same prefix
- let name_matches: Vec<(String, i64)> = all_matches
- .iter()
- .map(|(n, s, _)| (n.clone(), *s))
- .collect();
- let is_prefix_completion = check_for_prefix_matches(&name_matches, &name);
-
- // Reconstruct the full path
- let completed_text = if query.starts_with('/') {
- // Absolute path
- let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or("");
- if dir_part.is_empty() {
- format!("/{}", name)
- } else {
- format!("{}/{}", dir_part, name)
- }
- } else if query.starts_with("~/") {
- // Home directory
- let query_without_tilde = &query[2..];
- let dir_part = query_without_tilde.rsplitn(2, '/').nth(1).unwrap_or("");
- if dir_part.is_empty() {
- format!("~/{}", name)
- } else {
- format!("~/{}/{}", dir_part, name)
- }
- } else {
- // Relative path
- let dir_part = query.rsplitn(2, '/').nth(1).unwrap_or("");
- if dir_part.is_empty() {
- name.clone()
- } else {
- format!("{}/{}", dir_part, name)
- }
- };
-
- Some(CompletionResult {
- completed_text,
- score,
- is_directory,
- is_prefix_completion,
- })
-}
-
-/// Check if there are multiple matches that start with the best match's name
-/// Returns true if the best match is a prefix of other matches
-fn check_for_prefix_matches(matches: &[(String, i64)], best_match: &str) -> bool {
- if matches.len() <= 1 {
- return false;
- }
-
- // Count how many matches start with the best_match text
- let prefix_count = matches
- .iter()
- .filter(|(name, _)| name.starts_with(best_match) && name != best_match)
- .count();
-
- prefix_count > 0
-}
-
-/// Perform fuzzy completion on the current input
-/// Returns the best completion (command or path) with appropriate suffix (space or slash)
-pub fn fuzzy_complete(
- input: &str,
- executables: &BTreeMap<String, PathBuf>,
- cwd: Option<&PathBuf>,
-) -> Option<String> {
- // If input is empty, nothing to complete
- if input.is_empty() {
- return None;
- }
-
- // Extract the last word/token to complete
- // For simplicity, we'll complete the entire input if it's a single word,
- // or the last space-separated token if there are multiple words
- let tokens: Vec<&str> = input.split_whitespace().collect();
- let to_complete = if tokens.is_empty() {
- input
- } else {
- tokens.last().unwrap()
- };
-
- // Get both command and path completions
- let command_result = fuzzy_complete_command(to_complete, executables);
- let path_result = fuzzy_complete_path(to_complete, cwd);
-
- // Choose the better match based on score
- match (command_result, path_result) {
- (Some(cmd), Some(path)) => {
- // Both matched, use the higher score
- let (result, completion_type, is_dir, is_prefix) = if cmd.score >= path.score {
- (
- cmd.completed_text,
- CompletionType::Command,
- false,
- cmd.is_prefix_completion,
- )
- } else {
- (
- path.completed_text,
- CompletionType::Path,
- path.is_directory,
- path.is_prefix_completion,
- )
- };
-
- // Add appropriate suffix (skip slash if this is a prefix completion)
- let suffix = get_completion_suffix(completion_type, is_dir, is_prefix);
- let completed = format!("{}{}", result, suffix);
-
- // Replace the last token with the completion
- Some(replace_last_token(input, &completed))
- },
- (Some(cmd), None) => {
- // Command completion - add space (unless it's a prefix completion)
- let suffix = if cmd.is_prefix_completion { "" } else { " " };
- let completed = format!("{}{}", cmd.completed_text, suffix);
- Some(replace_last_token(input, &completed))
- },
- (None, Some(path)) => {
- // Path completion - add slash if directory (unless it's a prefix completion)
- let suffix = if path.is_prefix_completion {
- ""
- } else if path.is_directory {
- "/"
- } else {
- ""
- };
- let completed = format!("{}{}", path.completed_text, suffix);
- Some(replace_last_token(input, &completed))
- },
- (None, None) => None,
- }
-}
-
-/// Get the appropriate suffix for a completion
-fn get_completion_suffix(
- completion_type: CompletionType,
- is_directory: bool,
- is_prefix_completion: bool,
-) -> &'static str {
- // No suffix for prefix completions
- if is_prefix_completion {
- return "";
- }
-
- match completion_type {
- CompletionType::Command => " ",
- CompletionType::Path => {
- if is_directory {
- "/"
- } else {
- ""
- }
- },
- }
-}
-
-/// Replace the last whitespace-separated token in the input with the completion
-fn replace_last_token(input: &str, completion: &str) -> String {
- let tokens: Vec<&str> = input.split_whitespace().collect();
- if tokens.is_empty() {
- return completion.to_string();
- }
-
- if tokens.len() == 1 {
- completion.to_string()
- } else {
- // Join all tokens except the last one, then add the completion
- let mut result = tokens[..tokens.len() - 1].join(" ");
- result.push(' ');
- result.push_str(completion);
- result
- }
-}
diff --git a/default-plugins/sequence/src/ui/mod.rs b/default-plugins/sequence/src/ui/mod.rs
index 6d1ec303..7255fa35 100644
--- a/default-plugins/sequence/src/ui/mod.rs
+++ b/default-plugins/sequence/src/ui/mod.rs
@@ -1,5 +1,3 @@
pub mod components;
-pub mod fuzzy_complete;
pub mod layout_calculations;
-pub mod text_input;
pub mod truncation;
diff --git a/default-plugins/sequence/src/ui/text_input.rs b/default-plugins/sequence/src/ui/text_input.rs
deleted file mode 100644
index 2694e17a..00000000
--- a/default-plugins/sequence/src/ui/text_input.rs
+++ /dev/null
@@ -1,997 +0,0 @@
-use zellij_tile::prelude::*;
-
-const MAX_UNDO_STACK_SIZE: usize = 100;
-
-/// Action returned by TextInput after handling a key event
-#[derive(Debug, Clone, PartialEq)]
-#[cfg_attr(test, derive(Eq))]
-pub enum InputAction {
- /// Continue editing
- Continue,
- /// User pressed Enter to submit
- Submit,
- /// User pressed Esc to cancel
- Cancel,
- /// User pressed Tab to request completion
- Complete,
- /// Key was not handled by the input
- NoAction,
-}
-
-/// A reusable text input component with cursor support and standard editing keybindings
-#[derive(Debug, Clone)]
-pub struct TextInput {
- buffer: String,
- cursor_position: usize, // Character position (0-based), NOT byte position
- undo_stack: Vec<(String, usize)>, // (buffer, cursor) snapshots
- redo_stack: Vec<(String, usize)>,
- last_edit_was_insert: bool, // For coalescing consecutive inserts
-}
-
-impl TextInput {
- /// Create a new TextInput with the given initial text
- /// Cursor is positioned at the end of the text
- pub fn new(initial_text: String) -> Self {
- let cursor_position = initial_text.chars().count();
- Self {
- buffer: initial_text,
- cursor_position,
- undo_stack: Vec::new(),
- redo_stack: Vec::new(),
- last_edit_was_insert: false,
- }
- }
-
- /// Create an empty TextInput
- pub fn empty() -> Self {
- Self::new(String::new())
- }
-
- /// Get the current text
- pub fn get_text(&self) -> &str {
- &self.buffer
- }
-
- /// Get the cursor position (in characters, not bytes)
- pub fn get_cursor_position(&self) -> usize {
- self.cursor_position
- }
-
- /// Check if the input is empty
- pub fn is_empty(&self) -> bool {
- self.buffer.is_empty()
- }
-
- /// Get a shorthand for cursor_position
- pub fn cursor_position(&self) -> usize {
- self.cursor_position
- }
-
- /// Get mutable access to the underlying buffer for direct manipulation
- pub fn get_text_mut(&mut self) -> &mut String {
- &mut self.buffer
- }
-
- /// Set the text and move cursor to the end
- pub fn set_text(&mut self, text: String) {
- self.break_coalescing();
- self.save_undo_state();
- self.cursor_position = text.chars().count();
- self.buffer = text;
- }
-
- /// Set cursor position (clamped to text length)
- pub fn set_cursor_position(&mut self, pos: usize) {
- let text_len = self.buffer.chars().count();
- self.cursor_position = pos.min(text_len);
- }
-
- /// Clear all text and reset cursor
- pub fn clear(&mut self) {
- self.break_coalescing();
- self.save_undo_state();
- self.buffer.clear();
- self.cursor_position = 0;
- }
-
- /// Insert a character at the current cursor position
- pub fn insert_char(&mut self, c: char) {
- self.save_undo_state_unless_coalescing();
- // Convert cursor position (char index) to byte index
- let byte_index = self.char_index_to_byte_index(self.cursor_position);
- self.buffer.insert(byte_index, c);
- self.cursor_position += 1;
- }
-
- /// Delete the character before the cursor (backspace)
- pub fn backspace(&mut self) {
- if self.cursor_position > 0 {
- self.break_coalescing();
- self.save_undo_state();
- self.cursor_position -= 1;
- let byte_index = self.char_index_to_byte_index(self.cursor_position);
- self.buffer.remove(byte_index);
- }
- }
-
- /// Delete the character at the cursor position (delete key)
- pub fn delete(&mut self) {
- let len = self.buffer.chars().count();
- if self.cursor_position < len {
- self.break_coalescing();
- self.save_undo_state();
- let byte_index = self.char_index_to_byte_index(self.cursor_position);
- self.buffer.remove(byte_index);
- }
- }
-
- /// Delete the word before the cursor (Ctrl/Alt + Backspace)
- pub fn delete_word_backward(&mut self) {
- if self.cursor_position == 0 {
- return;
- }
-
- self.break_coalescing();
- self.save_undo_state();
-
- let old_position = self.cursor_position;
- self.move_word_left();
- let new_position = self.cursor_position;
-
- // Delete from new position to old position
- let start_byte = self.char_index_to_byte_index(new_position);
- let end_byte = self.char_index_to_byte_index(old_position);
- self.buffer.drain(start_byte..end_byte);
- }
-
- /// Delete the word after the cursor (Ctrl/Alt + Delete)
- pub fn delete_word_forward(&mut self) {
- let chars: Vec<char> = self.buffer.chars().collect();
- let len = chars.len();
-
- if self.cursor_position >= len {
- return;
- }
-
- self.break_coalescing();
- self.save_undo_state();
-
- let start_position = self.cursor_position;
- let mut end_position = start_position;
-
- // Skip the current word
- while end_position < len && !chars[end_position].is_whitespace() {
- end_position += 1;
- }
-
- // Skip any whitespace after the word
- while end_position < len && chars[end_position].is_whitespace() {
- end_position += 1;
- }
-
- // Delete from start to end position
- let start_byte = self.char_index_to_byte_index(start_position);
- let end_byte = self.char_index_to_byte_index(end_position);
- self.buffer.drain(start_byte..end_byte);
- }
-
- /// Move cursor one position to the left
- pub fn move_left(&mut self) {
- if self.cursor_position > 0 {
- self.break_coalescing();
- self.cursor_position -= 1;
- }
- }
-
- /// Move cursor one position to the right
- pub fn move_right(&mut self) {
- let len = self.buffer.chars().count();
- if self.cursor_position < len {
- self.break_coalescing();
- self.cursor_position += 1;
- }
- }
-
- /// Move cursor to the start of the text (Ctrl-A / Home)
- pub fn move_to_start(&mut self) {
- self.break_coalescing();
- self.cursor_position = 0;
- }
-
- /// Move cursor to the end of the text (Ctrl-E / End)
- pub fn move_to_end(&mut self) {
- self.break_coalescing();
- self.cursor_position = self.buffer.chars().count();
- }
-
- /// Move cursor to the start of the previous word (Ctrl/Alt + Left)
- pub fn move_word_left(&mut self) {
- if self.cursor_position == 0 {
- return;
- }
-
- self.break_coalescing();
-
- let chars: Vec<char> = self.buffer.chars().collect();
- let mut pos = self.cursor_position;
-
- // Skip any whitespace immediately to the left
- while pos > 0 && chars[pos - 1].is_whitespace() {
- pos -= 1;
- }
-
- // Skip the word characters
- while pos > 0 && !chars[pos - 1].is_whitespace() {
- pos -= 1;
- }
-
- self.cursor_position = pos;
- }
-
- /// Move cursor to the start of the next word (Ctrl/Alt + Right)
- pub fn move_word_right(&mut self) {
- let chars: Vec<char> = self.buffer.chars().collect();
- let len = chars.len();
-
- if self.cursor_position >= len {
- return;
- }
-
- self.break_coalescing();
-
- let mut pos = self.cursor_position;
-
- // Skip the current word
- while pos < len && !chars[pos].is_whitespace() {
- pos += 1;
- }
-
- // Skip any whitespace
- while pos < len && chars[pos].is_whitespace() {
- pos += 1;
- }
-
- self.cursor_position = pos;
- }
-
- /// Handle a key event and return the appropriate action
- /// This is the main entry point for key handling
- pub fn handle_key(&mut self, key: KeyWithModifier) -> InputAction {
- // Check for Ctrl modifiers
- if key.has_modifiers(&[KeyModifier::Ctrl]) {
- match key.bare_key {
- BareKey::Char('a') => {
- self.move_to_start();
- return InputAction::Continue;
- },
- BareKey::Char('e') => {
- self.move_to_end();
- return InputAction::Continue;
- },
- BareKey::Char('c') => {
- // Ctrl-C clears the prompt
- return InputAction::Cancel;
- },
- BareKey::Char('z') => {
- // Ctrl-Z: Undo
- self.undo();
- return InputAction::Continue;
- },
- BareKey::Char('y') => {
- // Ctrl-Y: Redo
- self.redo();
- return InputAction::Continue;
- },
- BareKey::Left => {
- self.move_word_left();
- return InputAction::Continue;
- },
- BareKey::Right => {
- self.move_word_right();
- return InputAction::Continue;
- },
- BareKey::Backspace => {
- self.delete_word_backward();
- return InputAction::Continue;
- },
- BareKey::Delete => {
- self.delete_word_forward();
- return InputAction::Continue;
- },
- _ => {},
- }
- }
-
- // Check for Ctrl+Shift modifiers (alternative redo: Ctrl+Shift+Z)
- if key.has_modifiers(&[KeyModifier::Ctrl, KeyModifier::Shift]) {
- match key.bare_key {
- BareKey::Char('Z') => {
- // Ctrl-Shift-Z: Redo (alternative)
- self.redo();
- return InputAction::Continue;
- },
- _ => {},
- }
- }
-
- // Check for Alt modifiers
- if key.has_modifiers(&[KeyModifier::Alt]) {
- match key.bare_key {
- BareKey::Left => {
- self.move_word_left();
- return InputAction::Continue;
- },
- BareKey::Right => {
- self.move_word_right();
- return InputAction::Continue;
- },
- BareKey::Backspace => {
- self.delete_word_backward();
- return InputAction::Continue;
- },
- BareKey::Delete => {
- self.delete_word_forward();
- return InputAction::Continue;
- },
- _ => {},
- }
- }
-
- // Handle bare keys (no modifiers)
- match key.bare_key {
- BareKey::Enter => InputAction::Submit,
- BareKey::Esc => InputAction::Cancel,
- BareKey::Tab => InputAction::Complete,
- BareKey::Backspace => {
- self.backspace();
- InputAction::Continue
- },
- BareKey::Delete => {
- self.delete();
- InputAction::Continue
- },
- BareKey::Left => {
- self.move_left();
- InputAction::Continue
- },
- BareKey::Right => {
- self.move_right();
- InputAction::Continue
- },
- BareKey::Home => {
- self.move_to_start();
- InputAction::Continue
- },
- BareKey::End => {
- self.move_to_end();
- InputAction::Continue
- },
- BareKey::Char(c) => {
- self.insert_char(c);
- InputAction::Continue
- },
- _ => InputAction::NoAction,
- }
- }
-
- /// Helper: Convert character index to byte index
- fn char_index_to_byte_index(&self, char_index: usize) -> usize {
- self.buffer
- .char_indices()
- .nth(char_index)
- .map(|(byte_idx, _)| byte_idx)
- .unwrap_or(self.buffer.len())
- }
-
- /// Save current state to undo stack before making changes
- fn save_undo_state(&mut self) {
- if self.undo_stack.len() >= MAX_UNDO_STACK_SIZE {
- self.undo_stack.remove(0);
- }
- self.undo_stack
- .push((self.buffer.clone(), self.cursor_position));
- self.redo_stack.clear();
- }
-
- /// Save state only if not coalescing with previous insert
- fn save_undo_state_unless_coalescing(&mut self) {
- if !self.last_edit_was_insert {
- self.save_undo_state();
- }
- self.last_edit_was_insert = true;
- }
-
- /// Mark that a non-insert edit occurred (breaks coalescing)
- fn break_coalescing(&mut self) {
- self.last_edit_was_insert = false;
- }
-
- /// Undo last change
- pub fn undo(&mut self) -> bool {
- if let Some((buffer, cursor)) = self.undo_stack.pop() {
- self.redo_stack
- .push((self.buffer.clone(), self.cursor_position));
- self.buffer = buffer;
- self.cursor_position = cursor;
- self.break_coalescing();
- true
- } else {
- false
- }
- }
-
- /// Redo last undone change
- pub fn redo(&mut self) -> bool {
- if let Some((buffer, cursor)) = self.redo_stack.pop() {
- self.undo_stack
- .push((self.buffer.clone(), self.cursor_position));
- self.buffer = buffer;
- self.cursor_position = cursor;
- self.break_coalescing();
- true
- } else {
- false
- }
- }
-
- /// Check if undo is available
- pub fn can_undo(&self) -> bool {
- !self.undo_stack.is_empty()
- }
-
- /// Check if redo is available
- pub fn can_redo(&self) -> bool {
- !self.redo_stack.is_empty()
- }
-
- pub fn drain_text(&mut self) -> String {
- self.cursor_position = 0;
- self.buffer.drain(..).collect()
- }
-}
-
-// run with:
-// cargo test --lib --target x86_64-unknown-linux-gnu
-//
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_new_and_empty() {
- let input = TextInput::new("hello".to_string());
- assert_eq!(input.get_text(), "hello");
- assert_eq!(input.get_cursor_position(), 5);
-
- let empty = TextInput::empty();
- assert_eq!(empty.get_text(), "");
- assert_eq!(empty.get_cursor_position(), 0);
- }
-
- #[test]
- fn test_insert_char() {
- let mut input = TextInput::new("helo".to_string());
- input.cursor_position = 3; // Position after "hel"
- input.insert_char('l');
- assert_eq!(input.get_text(), "hello");
- assert_eq!(input.get_cursor_position(), 4);
- }
-
- #[test]
- fn test_backspace() {
- let mut input = TextInput::new("hello".to_string());
- input.backspace();
- assert_eq!(input.get_text(), "hell");
- assert_eq!(input.get_cursor_position(), 4);
-
- // Backspace at start does nothing
- input.cursor_position = 0;
- input.backspace();
- assert_eq!(input.get_text(), "hell");
- assert_eq!(input.get_cursor_position(), 0);
- }
-
- #[test]
- fn test_delete() {
- let mut input = TextInput::new("hello".to_string());
- input.cursor_position = 0;
- input.delete();
- assert_eq!(input.get_text(), "ello");
- assert_eq!(input.get_cursor_position(), 0);
-
- // Delete at end does nothing
- input.move_to_end();
- input.delete();
- assert_eq!(input.get_text(), "ello");
- }
-
- #[test]
- fn test_cursor_movement() {
- let mut input = TextInput::new("hello".to_string());
- assert_eq!(input.get_cursor_position(), 5);
-
- input.move_left();
- assert_eq!(input.get_cursor_position(), 4);
-
- input.move_right();
- assert_eq!(input.get_cursor_position(), 5);
-
- input.move_to_start();
- assert_eq!(input.get_cursor_position(), 0);
-
- input.move_to_end();
- assert_eq!(input.get_cursor_position(), 5);
- }
-
- #[test]
- fn test_unicode_support() {
- let mut input = TextInput::new("hello 🦀 world".to_string());
- assert_eq!(input.get_cursor_position(), 13); // 13 characters
-
- input.cursor_position = 6; // After "hello "
- input.insert_char('🐱');
- assert_eq!(input.get_text(), "hello 🐱🦀 world");
- }
-
- #[test]
- fn test_word_jump_right() {
- let mut input = TextInput::new("hello world foo bar".to_string());
- input.cursor_position = 0;
-
- // Jump from start to "world"
- input.move_word_right();
- assert_eq!(input.get_cursor_position(), 6); // After "hello "
-
- // Jump to "foo"
- input.move_word_right();
- assert_eq!(input.get_cursor_position(), 12); // After "world "
-
- // Jump to "bar"
- input.move_word_right();
- assert_eq!(input.get_cursor_position(), 16); // After "foo "
-
- // Jump to end
- input.move_word_right();
- assert_eq!(input.get_cursor_position(), 19); // At end
- }
-
- #[test]
- fn test_word_jump_left() {
- let mut input = TextInput::new("hello world foo bar".to_string());
- input.move_to_end();
- assert_eq!(input.get_cursor_position(), 19);
-
- // Jump back to "bar"
- input.move_word_left();
- assert_eq!(input.get_cursor_position(), 16); // Start of "bar"
-
- // Jump back to "foo"
- input.move_word_left();
- assert_eq!(input.get_cursor_position(), 12); // Start of "foo"
-
- // Jump back to "world"
- input.move_word_left();
- assert_eq!(input.get_cursor_position(), 6); // Start of "world"
-
- // Jump back to "hello"
- input.move_word_left();
- assert_eq!(input.get_cursor_position(), 0); // Start of "hello"
- }
-
- #[test]
- fn test_word_jump_with_multiple_spaces() {
- let mut input = TextInput::new("hello world".to_string());
- input.cursor_position = 0;
-
- // Jump over multiple spaces
- input.move_word_right();
- assert_eq!(input.get_cursor_position(), 8); // After "hello ", at start of "world"
-
- // Jump back should skip spaces
- input.move_word_left();
- assert_eq!(input.get_cursor_position(), 0); // Back to start of "hello"
- }
-
- #[test]
- fn test_word_jump_boundaries() {
- let mut input = TextInput::new("test".to_string());
-
- // At start - word left does nothing
- input.cursor_position = 0;
- input.move_word_left();
- assert_eq!(input.get_cursor_position(), 0);
-
- // At end - word right does nothing
- input.move_to_end();
- let end_pos = input.get_cursor_position();
- input.move_word_right();
- assert_eq!(input.get_cursor_position(), end_pos);
- }
-
- #[test]
- fn test_up_down_arrows() {
- let mut input = TextInput::new("hello world".to_string());
-
- // Start in the middle
- input.cursor_position = 5;
- assert_eq!(input.get_cursor_position(), 5);
-
- // Up arrow should go to start
- input.move_to_start();
- assert_eq!(input.get_cursor_position(), 0);
-
- // Move back to middle
- input.cursor_position = 5;
-
- // Down arrow should go to end
- input.move_to_end();
- assert_eq!(input.get_cursor_position(), 11);
- }
-
- #[test]
- fn test_delete_word_backward() {
- let mut input = TextInput::new("hello world foo".to_string());
-
- // Delete "foo" from end
- input.move_to_end();
- input.delete_word_backward();
- assert_eq!(input.get_text(), "hello world ");
- assert_eq!(input.get_cursor_position(), 12);
-
- // Delete "world "
- input.delete_word_backward();
- assert_eq!(input.get_text(), "hello ");
- assert_eq!(input.get_cursor_position(), 6);
-
- // Delete "hello "
- input.delete_word_backward();
- assert_eq!(input.get_text(), "");
- assert_eq!(input.get_cursor_position(), 0);
-
- // Delete on empty buffer does nothing
- input.delete_word_backward();
- assert_eq!(input.get_text(), "");
- assert_eq!(input.get_cursor_position(), 0);
- }
-
- #[test]
- fn test_delete_word_backward_middle() {
- let mut input = TextInput::new("hello world foo".to_string());
-
- // Position in middle of "world"
- input.cursor_position = 8; // After "hello wo"
- input.delete_word_backward();
- assert_eq!(input.get_text(), "hello rld foo");
- assert_eq!(input.get_cursor_position(), 6); // After "hello "
- }
-
- #[test]
- fn test_delete_word_forward() {
- let mut input = TextInput::new("hello world foo".to_string());
-
- // Delete "hello " from start
- input.cursor_position = 0;
- input.delete_word_forward();
- assert_eq!(input.get_text(), "world foo");
- assert_eq!(input.get_cursor_position(), 0);
-
- // Delete "world "
- input.delete_word_forward();
- assert_eq!(input.get_text(), "foo");
- assert_eq!(input.get_cursor_position(), 0);
-
- // Delete "foo"
- input.delete_word_forward();
- assert_eq!(input.get_text(), "");
- assert_eq!(input.get_cursor_position(), 0);
-
- // Delete on empty buffer does nothing
- input.delete_word_forward();
- assert_eq!(input.get_text(), "");
- assert_eq!(input.get_cursor_position(), 0);
- }
-
- #[test]
- fn test_delete_word_forward_middle() {
- let mut input = TextInput::new("hello world foo".to_string());
-
- // Position in middle of "world"
- input.cursor_position = 8; // After "hello wo"
- input.delete_word_forward();
- assert_eq!(input.get_text(), "hello wofoo");
- assert_eq!(input.get_cursor_position(), 8); // Same position, text deleted forward
- }
-
- #[test]
- fn test_delete_word_with_multiple_spaces() {
- let mut input = TextInput::new("hello world".to_string());
-
- // Delete forward includes trailing spaces
- input.cursor_position = 0;
- input.delete_word_forward();
- assert_eq!(input.get_text(), "world");
- assert_eq!(input.get_cursor_position(), 0);
- }
-
- #[test]
- fn test_undo_redo_basic() {
- let mut input = TextInput::empty();
-
- // Type "hello"
- input.insert_char('h');
- input.insert_char('e');
- input.insert_char('l');
- input.insert_char('l');
- input.insert_char('o');
- assert_eq!(input.get_text(), "hello");
-
- // Undo should remove all characters (coalesced into one undo entry)
- assert!(input.can_undo());
- assert!(input.undo());
- assert_eq!(input.get_text(), "");
- assert_eq!(input.get_cursor_position(), 0);
-
- // Redo should restore "hello"
- assert!(input.can_redo());
- assert!(input.redo());
- assert_eq!(input.get_text(), "hello");
- assert_eq!(input.get_cursor_position(), 5);
- }
-
- #[test]
- fn test_undo_coalescing_breaks_on_cursor_move() {
- let mut input = TextInput::empty();
-
- // Type "he"
- input.insert_char('h');
- input.insert_char('e');
-
- // Move cursor to start (breaks coalescing)
- input.move_to_start();
-
- // Type "llo"
- input.insert_char('l');
- input.insert_char('l');
- input.insert_char('o');
-
- assert_eq!(input.get_text(), "llohe");
-
- // First undo removes "llo" (second coalesced group)
- input.undo();
- assert_eq!(input.get_text(), "he");
-
- // Second undo removes "he" (first coalesced group)
- input.undo();
- assert_eq!(input.get_text(), "");
- }
-
- #[test]
- fn test_undo_backspace() {
- let mut input = TextInput::new("hello".to_string());
-
- // Backspace once
- input.backspace();
- assert_eq!(input.get_text(), "hell");
-
- // Undo should restore "hello"
- input.undo();
- assert_eq!(input.get_text(), "hello");
- assert_eq!(input.get_cursor_position(), 5);
- }
-
- #[test]
- fn test_undo_delete() {
- let mut input = TextInput::new("hello".to_string());
- input.cursor_position = 0;
-
- // Delete first character
- input.delete();
- assert_eq!(input.get_text(), "ello");
-
- // Undo should restore "hello"
- input.undo();
- assert_eq!(input.get_text(), "hello");
- assert_eq!(input.get_cursor_position(), 0);
- }
-
- #[test]
- fn test_undo_word_delete() {
- let mut input = TextInput::new("hello world".to_string());
-
- // Delete "world" backward
- input.delete_word_backward();
- assert_eq!(input.get_text(), "hello ");
-
- // Undo should restore "hello world"
- input.undo();
- assert_eq!(input.get_text(), "hello world");
- assert_eq!(input.get_cursor_position(), 11);
- }
-
- #[test]
- fn test_undo_clear() {
- let mut input = TextInput::new("hello world".to_string());
-
- // Clear the buffer
- input.clear();
- assert_eq!(input.get_text(), "");
-
- // Undo should restore the text
- input.undo();
- assert_eq!(input.get_text(), "hello world");
- }
-
- #[test]
- fn test_undo_set_text() {
- let mut input = TextInput::new("hello".to_string());
-
- // Replace with new text
- input.set_text("goodbye".to_string());
- assert_eq!(input.get_text(), "goodbye");
-
- // Undo should restore "hello"
- input.undo();
- assert_eq!(input.get_text(), "hello");
- }
-
- #[test]
- fn test_redo_clears_on_new_edit() {
- let mut input = TextInput::empty();
-
- // Type "hello"
- input.insert_char('h');
- input.insert_char('e');
- input.insert_char('l');
- input.insert_char('l');
- input.insert_char('o');
-
- // Undo
- input.undo();
- assert_eq!(input.get_text(), "");
- assert!(input.can_redo());
-
- // Make a new edit (should clear redo stack)
- input.insert_char('x');
- assert!(!input.can_redo());
- }
-
- #[test]
- fn test_multiple_undo_redo() {
- let mut input = TextInput::empty();
-
- // First edit: type "hello"
- for c in "hello".chars() {
- input.insert_char(c);
- }
-
- // Break coalescing
- input.move_left();
-
- // Second edit: type "world"
- for c in "world".chars() {
- input.insert_char(c);
- }
-
- assert_eq!(input.get_text(), "hellworldo");
-
- // Undo "world"
- input.undo();
- assert_eq!(input.get_text(), "hello");
-
- // Undo "hello"
- input.undo();
- assert_eq!(input.get_text(), "");
-
- // Redo "hello"
- input.redo();
- assert_eq!(input.get_text(), "hello");
-
- // Redo "world"
- input.redo();
- assert_eq!(input.get_text(), "hellworldo");
- }
-
- #[test]
- fn test_undo_stack_limit() {
- let mut input = TextInput::empty();
-
- // Perform 102 separate edits (breaking coalescing each time)
- for _i in 0..102 {
- input.backspace(); // Break coalescing
- input.insert_char('x');
- }
-
- // Should have at most 100 undo entries
- let mut undo_count = 0;
- while input.undo() {
- undo_count += 1;
- }
-
- // We should have 100 undo entries (the stack limit)
- // Plus the final state change from the last coalescing break
- assert!(
- undo_count <= 100,
- "Undo count should be at most 100, got {}",
- undo_count
- );
- }
-
- #[test]
- fn test_undo_redo_empty_stack() {
- let mut input = TextInput::empty();
-
- // Undo on empty stack should return false
- assert!(!input.can_undo());
- assert!(!input.undo());
-
- // Redo on empty stack should return false
- assert!(!input.can_redo());
- assert!(!input.redo());
- }
-
- #[test]
- fn test_undo_restores_cursor_position() {
- let mut input = TextInput::new("hello world".to_string());
-
- // Move cursor to position 5 (before "world")
- input.cursor_position = 5;
-
- // Insert a space
- input.insert_char(' ');
- assert_eq!(input.get_text(), "hello world");
- assert_eq!(input.get_cursor_position(), 6);
-
- // Undo should restore both text and cursor position
- input.undo();
- assert_eq!(input.get_text(), "hello world");
- assert_eq!(input.get_cursor_position(), 5);
- }
-
- #[test]
- fn test_coalescing_consecutive_inserts() {
- let mut input = TextInput::empty();
-
- // Type several characters
- input.insert_char('a');
- input.insert_char('b');
- input.insert_char('c');
-
- assert_eq!(input.get_text(), "abc");
-
- // Single undo should remove all three (they were coalesced)
- input.undo();
- assert_eq!(input.get_text(), "");
-
- // No more undo available
- assert!(!input.can_undo());
- }
-
- #[test]
- fn test_backspace_breaks_coalescing() {
- let mut input = TextInput::empty();
-
- // Type "ab"
- input.insert_char('a');
- input.insert_char('b');
-
- // Backspace
- input.backspace();
- assert_eq!(input.get_text(), "a");
-
- // Type "c"
- input.insert_char('c');
- assert_eq!(input.get_text(), "ac");
-
- // Undo should remove just "c"
- input.undo();
- assert_eq!(input.get_text(), "a");
-
- // Undo should remove backspace operation
- input.undo();
- assert_eq!(input.get_text(), "ab");
-
- // Undo should remove "ab"
- input.undo();
- assert_eq!(input.get_text(), "");
- }
-}